Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add default_page_size to schema, field, and resolver #4081

Merged
merged 9 commits into from May 31, 2022
34 changes: 33 additions & 1 deletion guides/pagination/using_connections.md
Expand Up @@ -10,7 +10,7 @@ index: 2

GraphQL-Ruby ships with a few implementations of the {% internal_link "connection pattern", "pagination/connection_concepts" %} that you can use out of the box. They support Ruby Arrays, Mongoid, Sequel, and ActiveRecord.

Additionally, connections allow you to limit the number of items returned with [`max_page_size`](#max-page-size).
Additionally, connections allow you to limit the number of items returned with [`max_page_size`](#max-page-size) and set the default number of items returned with [`default_page_size`](#default-page-size).

## Make Connection Fields

Expand Down Expand Up @@ -102,3 +102,35 @@ end
```

To _remove_ a `max_page_size` setting, you can pass `nil`. That will allow unbounded collections to be returned to clients.

## Default Page Size

You can apply `default_page_size` to limit the number of items returned and queried from the database when no `first` or `last` is provided.

- __For the whole schema__, you can add it to your schema definition:

```ruby
class MyAppSchema < GraphQL::Schema
default_page_size 50
end
```

At runtime, that value will be applied to _every_ connection, unless an override is provided as described below.

- __For a given field__, add it to the field definition with a keyword:

```ruby
field :items, Item.connection_type, null: false,
default_page_size: 25
```

- __Dynamically__, you can add `default_page_size:` when you apply custom connection wrappers:

```ruby
def items
relation = object.items
Connections::ItemsConnection.new(relation, default_page_size: 10)
end
```

If `max_page_size` is set and `default_page_size` is higher than it, the `default_page_size` will be clamped down to match `max_page_size`. If both `default_page_size` and `max_page_size` are set to `nil`, unbounded collections will be returned.
4 changes: 2 additions & 2 deletions guides/queries/complexity_and_depth.md
Expand Up @@ -84,9 +84,9 @@ By default, GraphQL-Ruby calculates a complexity value for connection fields by:
- adding `1` for `pageInfo` and each of its subselections
- adding `1` for `count`, `totalCount`, or `total`
- adding `1` for the connection field itself
- multiplying the complexity of other fields by the largest possible page size, which is the greater of `first:` or `last:`, or if neither of those is given, the field's `max_page_size` or the schema's `default_max_page_size`.
- multiplying the complexity of other fields by the largest possible page size, which is the greater of `first:` or `last:`, or if neither of those are given it will go through each of `default_page_size`, the schema's `default_page_size`, `max_page_size`, and then the schema's `default_max_page_size`.

(If no max page size can be determined, then the analysis crashes with an internal error -- set `default_max_page_size` in your schema to prevent this.)
(If no default page size or max page size can be determined, then the analysis crashes with an internal error -- set `default_page_size` or `default_max_page_size` in your schema to prevent this.)

For example, this query has complexity `26`:

Expand Down
35 changes: 31 additions & 4 deletions lib/graphql/pagination/connection.rb
Expand Up @@ -56,8 +56,9 @@ def after
# @param last [Integer, nil] Limit parameter from the client, if provided
# @param before [String, nil] A cursor for pagination, if the client provided one.
# @param arguments [Hash] The arguments to the field that returned the collection wrapped by this connection
# @param max_page_size [Integer, nil] A configured value to cap the result size. Applied as `first` if neither first or last are given.
def initialize(items, parent: nil, field: nil, context: nil, first: nil, after: nil, max_page_size: :not_given, last: nil, before: nil, edge_class: nil, arguments: nil)
# @param max_page_size [Integer, nil] A configured value to cap the result size. Applied as `first` if neither first or last are given and no `default_page_size` is set.
# @param default_page_size [Integer, nil] A configured value to determine the result size when neither first or last are given.
def initialize(items, parent: nil, field: nil, context: nil, first: nil, after: nil, max_page_size: :not_given, default_page_size: :not_given, last: nil, before: nil, edge_class: nil, arguments: nil)
@items = items
@parent = parent
@context = context
Expand All @@ -76,6 +77,12 @@ def initialize(items, parent: nil, field: nil, context: nil, first: nil, after:
else
max_page_size
end
@has_default_page_size_override = default_page_size != :not_given
@default_page_size = if default_page_size == :not_given
nil
else
default_page_size
end
end

def max_page_size=(new_value)
Expand All @@ -95,16 +102,36 @@ def has_max_page_size_override?
@has_max_page_size_override
end

def default_page_size=(new_value)
@has_default_page_size_override = true
@default_page_size = new_value
end

def default_page_size
if @has_default_page_size_override
@default_page_size
else
context.schema.default_page_size
end
end

def has_default_page_size_override?
@has_default_page_size_override
end

attr_writer :first
# @return [Integer, nil]
# A clamped `first` value.
# (The underlying instance variable doesn't have limits on it.)
# If neither `first` nor `last` is given, but `max_page_size` is present, max_page_size is used for first.
# If neither `first` nor `last` is given, but `default_page_size` is
# present, default_page_size is used for first. If `default_page_size`
# is greater than `max_page_size``, it'll be clamped down to
# `max_page_size`. If `default_page_size` is nil, use `max_page_size`.
def first
@first ||= begin
capped = limit_pagination_argument(@first_value, max_page_size)
if capped.nil? && last.nil?
capped = max_page_size
capped = limit_pagination_argument(default_page_size, max_page_size) || max_page_size
end
capped
end
Expand Down
1 change: 1 addition & 0 deletions lib/graphql/pagination/connections.rb
Expand Up @@ -70,6 +70,7 @@ def wrap(field, parent, items, arguments, context)
parent: parent,
field: field,
max_page_size: field.has_max_page_size? ? field.max_page_size : context.schema.default_max_page_size,
default_page_size: field.has_default_page_size? ? field.default_page_size : context.schema.default_page_size,
first: arguments[:first],
after: arguments[:after],
last: arguments[:last],
Expand Down
8 changes: 8 additions & 0 deletions lib/graphql/schema.rb
Expand Up @@ -506,6 +506,14 @@ def default_max_page_size(new_default_max_page_size = nil)
end
end

def default_page_size(new_default_page_size = nil)
if new_default_page_size
@default_page_size = new_default_page_size
else
@default_page_size || find_inherited_value(:default_page_size)
end
end

def query_execution_strategy(new_query_execution_strategy = nil)
if new_query_execution_strategy
@query_execution_strategy = new_query_execution_strategy
Expand Down
19 changes: 16 additions & 3 deletions lib/graphql/schema/field.rb
Expand Up @@ -200,6 +200,7 @@ def method_conflict_warning?
# @param connection [Boolean] `true` if this field should get automagic connection behavior; default is to infer by `*Connection` in the return type name
# @param connection_extension [Class] The extension to add, to implement connections. If `nil`, no extension is added.
# @param max_page_size [Integer, nil] For connections, the maximum number of items to return from this field, or `nil` to allow unlimited results.
# @param default_page_size [Integer, nil] For connections, the default number of items to return from this field, or `nil` to return unlimited results.
# @param introspection [Boolean] If true, this field will be marked as `#introspection?` and the name may begin with `__`
# @param resolver_class [Class] (Private) A {Schema::Resolver} which this field was derived from. Use `resolver:` to create a field with a resolver.
# @param arguments [{String=>GraphQL::Schema::Argument, Hash}] Arguments for this field (may be added in the block, also)
Expand All @@ -215,7 +216,7 @@ def method_conflict_warning?
# @param method_conflict_warning [Boolean] If false, skip the warning if this field's method conflicts with a built-in method
# @param validates [Array<Hash>] Configurations for validating this field
# @fallback_value [Object] A fallback value if the method is not defined
def initialize(type: nil, name: nil, owner: nil, null: nil, description: :not_given, deprecation_reason: nil, method: nil, hash_key: nil, dig: nil, resolver_method: nil, connection: nil, max_page_size: :not_given, scope: nil, introspection: false, camelize: true, trace: nil, complexity: nil, ast_node: nil, extras: EMPTY_ARRAY, extensions: EMPTY_ARRAY, connection_extension: self.class.connection_extension, resolver_class: nil, subscription_scope: nil, relay_node_field: false, relay_nodes_field: false, method_conflict_warning: true, broadcastable: nil, arguments: EMPTY_HASH, directives: EMPTY_HASH, validates: EMPTY_ARRAY, fallback_value: :not_given, &definition_block)
def initialize(type: nil, name: nil, owner: nil, null: nil, description: :not_given, deprecation_reason: nil, method: nil, hash_key: nil, dig: nil, resolver_method: nil, connection: nil, max_page_size: :not_given, default_page_size: :not_given, scope: nil, introspection: false, camelize: true, trace: nil, complexity: nil, ast_node: nil, extras: EMPTY_ARRAY, extensions: EMPTY_ARRAY, connection_extension: self.class.connection_extension, resolver_class: nil, subscription_scope: nil, relay_node_field: false, relay_nodes_field: false, method_conflict_warning: true, broadcastable: nil, arguments: EMPTY_HASH, directives: EMPTY_HASH, validates: EMPTY_ARRAY, fallback_value: :not_given, &definition_block)
if name.nil?
raise ArgumentError, "missing first `name` argument or keyword `name:`"
end
Expand Down Expand Up @@ -269,6 +270,8 @@ def initialize(type: nil, name: nil, owner: nil, null: nil, description: :not_gi
@connection = connection
@has_max_page_size = max_page_size != :not_given
@max_page_size = max_page_size == :not_given ? nil : max_page_size
@has_default_page_size = default_page_size != :not_given
@default_page_size = default_page_size == :not_given ? nil : default_page_size
@introspection = introspection
@extras = extras
if !broadcastable.nil?
Expand Down Expand Up @@ -464,11 +467,11 @@ def calculate_complexity(query:, nodes:, child_complexity:)
end

if max_possible_page_size.nil?
max_possible_page_size = max_page_size || query.schema.default_max_page_size
max_possible_page_size = default_page_size || query.schema.default_page_size || max_page_size || query.schema.default_max_page_size
end

if max_possible_page_size.nil?
raise GraphQL::Error, "Can't calculate complexity for #{path}, no `first:`, `last:`, `max_page_size` or `default_max_page_size`"
raise GraphQL::Error, "Can't calculate complexity for #{path}, no `first:`, `last:`, `default_page_size`, `max_page_size` or `default_max_page_size`"
else
metadata_complexity = 0
lookahead = GraphQL::Execution::Lookahead.new(query: query, field: self, ast_nodes: nodes, owner_type: owner)
Expand Down Expand Up @@ -545,6 +548,16 @@ def max_page_size
@max_page_size || (@resolver_class && @resolver_class.max_page_size)
end

# @return [Boolean] True if this field's {#default_page_size} should override the schema default.
def has_default_page_size?
@has_default_page_size || (@resolver_class && @resolver_class.has_default_page_size?)
end

# @return [Integer, nil] Applied to connections if {#has_default_page_size?}
def default_page_size
@default_page_size || (@resolver_class && @resolver_class.default_page_size)
end

class MissingReturnTypeError < GraphQL::Error; end
attr_writer :type

Expand Down
4 changes: 4 additions & 0 deletions lib/graphql/schema/field/connection_extension.rb
Expand Up @@ -47,6 +47,9 @@ def after_resolve(value:, object:, arguments:, context:, memo:)
if field.has_max_page_size? && !value.has_max_page_size_override?
value.max_page_size = field.max_page_size
end
if field.has_default_page_size? && !value.has_default_page_size_override?
value.default_page_size = field.default_page_size
end
if context.schema.new_connections? && (custom_t = context.schema.connections.edge_class_for_field(@field))
value.edge_class = custom_t
end
Expand All @@ -64,6 +67,7 @@ def after_resolve(value:, object:, arguments:, context:, memo:)
original_arguments,
field: field,
max_page_size: field.max_page_size,
default_page_size: field.default_page_size,
parent: object,
context: context,
)
Expand Down
21 changes: 21 additions & 0 deletions lib/graphql/schema/resolver.rb
Expand Up @@ -328,6 +328,27 @@ def has_max_page_size?
(!!defined?(@max_page_size)) || (superclass.respond_to?(:has_max_page_size?) && superclass.has_max_page_size?)
end

# Get or set the `default_page_size:` which will be configured for fields using this resolver
# (`nil` means "unlimited default page size".)
# @param default_page_size [Integer, nil] Set a new value
# @return [Integer, nil] The `default_page_size` assigned to fields that use this resolver
def default_page_size(new_default_page_size = :not_given)
if new_default_page_size != :not_given
@default_page_size = new_default_page_size
elsif defined?(@default_page_size)
@default_page_size
elsif superclass.respond_to?(:default_page_size)
superclass.default_page_size
else
nil
end
end

# @return [Boolean] `true` if this resolver or a superclass has an assigned `default_page_size`
def has_default_page_size?
(!!defined?(@default_page_size)) || (superclass.respond_to?(:has_default_page_size?) && superclass.has_default_page_size?)
end

# A non-normalized type configuration, without `null` applied
def type_expr
@type_expr || (superclass.respond_to?(:type_expr) ? superclass.type_expr : nil)
Expand Down
36 changes: 36 additions & 0 deletions spec/graphql/analysis/ast/query_complexity_spec.rb
Expand Up @@ -320,6 +320,42 @@
assert_equal 1 + 1 + 1 + (3 * 1) + 1, complexity
end
end

describe "Field-level default_page_size" do
let(:query_string) {%|
{
rebels {
shipsWithDefaultPageSize {
nodes { id }
}
}
}
|}

it "uses field default_page_size" do
complexity = reduce_result.first
assert_equal 1 + 1 + 1 + (500 * 1), complexity
end
end

describe "Schema-level default_page_size" do
let(:query) { GraphQL::Query.new(StarWars::SchemaWithDefaultPageSize, query_string) }
let(:query_string) {%|
{
rebels {
bases {
nodes { id }
totalCount
}
}
}
|}

it "uses schema default_page_size" do
complexity = reduce_result.first
assert_equal 1 + 1 + 1 + (2 * 1) + 1, complexity
end
end
end

describe "calucation complexity for a multiplex" do
Expand Down
Expand Up @@ -168,8 +168,8 @@ def total_count
log = with_active_record_log do
results = schema.execute(ALREADY_LOADED_QUERY_STRING)
end
# The max_page_size of 6 is applied to the results
assert_equal 6, results["data"]["preloadedItems"]["nodes"].size
# The default_page_size of 4 is applied to the results
assert_equal 4, results["data"]["preloadedItems"]["nodes"].size
assert_equal 1, log.split("\n").size, "It runs only one query"
decolorized_log = log.gsub(/\e\[([;\d]+)?m/, '').chomp
assert_operator decolorized_log, :end_with?, 'SELECT "foods".* FROM "foods"', "it's an unbounded select from the resolver"
Expand Down
2 changes: 1 addition & 1 deletion spec/graphql/pagination/connections_spec.rb
Expand Up @@ -35,7 +35,7 @@ class OtherArrayConnection < GraphQL::Pagination::ArrayConnection; end
end

it "returns connections by class, using inherited mappings and local overrides" do
field_defn = OpenStruct.new(has_max_page_size?: true, max_page_size: 10, type: GraphQL::Types::Relay::BaseConnection)
field_defn = OpenStruct.new(has_max_page_size?: true, max_page_size: 10, has_default_page_size?: true, default_page_size: 5, type: GraphQL::Types::Relay::BaseConnection)

set_wrapper = schema.connections.wrap(field_defn, nil, Set.new([1,2,3]), {}, nil)
assert_instance_of SetConnection, set_wrapper
Expand Down
44 changes: 44 additions & 0 deletions spec/graphql/schema/resolver_spec.rb
Expand Up @@ -907,6 +907,50 @@ class ObjectWithMaxPageSizeResolver < GraphQL::Schema::Object
assert_equal 10, ObjectWithMaxPageSizeResolver.fields["items"].max_page_size
end
end

describe "default_page_size" do
class NoDefaultPageSizeResolver < GraphQL::Schema::Resolver
end

class DefaultPageSizeBaseResolver < GraphQL::Schema::Resolver
default_page_size 10
end

class DefaultPageSizeSubclass < DefaultPageSizeBaseResolver
end

class DefaultPageSizeOverrideSubclass < DefaultPageSizeBaseResolver
default_page_size nil
end

class ObjectWithDefaultPageSizeResolver < GraphQL::Schema::Object
field :items, [String], null: false, resolver: DefaultPageSizeBaseResolver
end

it "defaults to absent" do
assert_nil NoDefaultPageSizeResolver.default_page_size
refute NoDefaultPageSizeResolver.has_default_page_size?
end

it "implements has_default_page_size?" do
assert DefaultPageSizeBaseResolver.has_default_page_size?
assert DefaultPageSizeSubclass.has_default_page_size?
assert DefaultPageSizeOverrideSubclass.has_default_page_size?
end

it "is inherited" do
assert_equal 10, DefaultPageSizeBaseResolver.default_page_size
assert_equal 10, DefaultPageSizeSubclass.default_page_size
end

it "is overridden by nil" do
assert_nil DefaultPageSizeOverrideSubclass.default_page_size
end

it "is passed along to the field" do
assert_equal 10, ObjectWithDefaultPageSizeResolver.fields["items"].default_page_size
end
end
end

describe "When the type is forgotten" do
Expand Down
4 changes: 4 additions & 0 deletions spec/graphql/schema_spec.rb
Expand Up @@ -31,6 +31,7 @@ class Subscription < GraphQL::Schema::Object
max_complexity 1
max_depth 2
default_max_page_size 3
default_page_size 2
error_bubbling false
disable_introspection_entry_points
orphan_types Jazz::Ensemble
Expand Down Expand Up @@ -63,6 +64,7 @@ class Subscription < GraphQL::Schema::Object
assert_equal base_schema.max_complexity, schema.max_complexity
assert_equal base_schema.max_depth, schema.max_depth
assert_equal base_schema.default_max_page_size, schema.default_max_page_size
assert_equal base_schema.default_page_size, schema.default_page_size
assert_equal base_schema.error_bubbling, schema.error_bubbling
assert_equal base_schema.orphan_types, schema.orphan_types
assert_equal base_schema.context_class, schema.context_class
Expand Down Expand Up @@ -111,6 +113,7 @@ class Subscription < GraphQL::Schema::Object
schema.max_complexity(10)
schema.max_depth(20)
schema.default_max_page_size(30)
schema.default_page_size(15)
schema.error_bubbling(true)
schema.orphan_types(Jazz::InstrumentType)
schema.directives([DummyFeature2])
Expand All @@ -132,6 +135,7 @@ class Subscription < GraphQL::Schema::Object
assert_equal 10, schema.max_complexity
assert_equal 20, schema.max_depth
assert_equal 30, schema.default_max_page_size
assert_equal 15, schema.default_page_size
assert schema.error_bubbling
assert_equal [Jazz::Ensemble, Jazz::InstrumentType], schema.orphan_types
assert_equal schema.directives, GraphQL::Schema.default_directives.merge(DummyFeature1.graphql_name => DummyFeature1, DummyFeature2.graphql_name => DummyFeature2)
Expand Down