Skip to content

Commit

Permalink
Merge pull request #4081 from connorshea/2999-add-default-page-size
Browse files Browse the repository at this point in the history
Add default_page_size to schema, field, and resolver
  • Loading branch information
rmosolgo committed May 31, 2022
2 parents dea841b + ae9c9ba commit 12a80ad
Show file tree
Hide file tree
Showing 15 changed files with 233 additions and 24 deletions.
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

0 comments on commit 12a80ad

Please sign in to comment.