lib/graphql/pagination/connection.rb



# frozen_string_literal: true

module GraphQL
  module Pagination
    # A Connection wraps a list of items and provides cursor-based pagination over it.
    #
    # Connections were introduced by Facebook's `Relay` front-end framework, but
    # proved to be generally useful for GraphQL APIs. When in doubt, use connections
    # to serve lists (like Arrays, ActiveRecord::Relations) via GraphQL.
    #
    # Unlike the previous connection implementation, these default to bidirectional pagination.
    #
    # Pagination arguments and context may be provided at initialization or assigned later (see {Schema::Field::ConnectionExtension}).
    class Connection
      class PaginationImplementationMissingError < GraphQL::Error
      end

      # @return [Object] A list object, from the application. This is the unpaginated value passed into the connection.
      attr_reader :items

      # @return [GraphQL::Query::Context]
      attr_accessor :context

      # @return [Object] the object this collection belongs to
      attr_accessor :parent

      # Raw access to client-provided values. (`max_page_size` not applied to first or last.)
      attr_accessor :before_value, :after_value, :first_value, :last_value

      # @return [String, nil] the client-provided cursor. `""` is treated as `nil`.
      def before
        if defined?(@before)
          @before
        else
          @before = @before_value == "" ? nil : @before_value
        end
      end

      # @return [String, nil] the client-provided cursor. `""` is treated as `nil`.
      def after
        if defined?(@after)
          @after
        else
          @after = @after_value == "" ? nil : @after_value
        end
      end

      # @return [Hash<Symbol => Object>] The field arguments from the field that returned this connection
      attr_accessor :arguments

      # @param items [Object] some unpaginated collection item, like an `Array` or `ActiveRecord::Relation`
      # @param context [Query::Context]
      # @param parent [Object] The object this collection belongs to
      # @param first [Integer, nil] The limit parameter from the client, if it provided one
      # @param after [String, nil] A cursor for pagination, if the client provided one
      # @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 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
        @field = field
        @first_value = first
        @after_value = after
        @last_value = last
        @before_value = before
        @arguments = arguments
        @edge_class = edge_class || self.class::Edge
        # This is only true if the object was _initialized_ with an override
        # or if one is assigned later.
        @has_max_page_size_override = max_page_size != :not_given
        @max_page_size = if max_page_size == :not_given
          nil
        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)
        @has_max_page_size_override = true
        @max_page_size = new_value
      end

      def max_page_size
        if @has_max_page_size_override
          @max_page_size
        else
          context.schema.default_max_page_size
        end
      end

      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 `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 = limit_pagination_argument(default_page_size, max_page_size) || max_page_size
          end
          capped
        end
      end

      # This is called by `Relay::RangeAdd` -- it can be overridden
      # when `item` needs some modifications based on this connection's state.
      #
      # @param item [Object] An item newly added to `items`
      # @return [Edge]
      def range_add_edge(item)
        edge_class.new(item, self)
      end

      attr_writer :last
      # @return [Integer, nil] A clamped `last` value. (The underlying instance variable doesn't have limits on it)
      def last
        @last ||= limit_pagination_argument(@last_value, max_page_size)
      end

      # @return [Array<Edge>] {nodes}, but wrapped with Edge instances
      def edges
        @edges ||= nodes.map { |n| @edge_class.new(n, self) }
      end

      # @return [Class] A wrapper class for edges of this connection
      attr_accessor :edge_class

      # @return [GraphQL::Schema::Field] The field this connection was returned by
      attr_accessor :field

      # @return [Array<Object>] A slice of {items}, constrained by {@first_value}/{@after_value}/{@last_value}/{@before_value}
      def nodes
        raise PaginationImplementationMissingError, "Implement #{self.class}#nodes to paginate `@items`"
      end

      # A dynamic alias for compatibility with {Relay::BaseConnection}.
      # @deprecated use {#nodes} instead
      def edge_nodes
        nodes
      end

      # The connection object itself implements `PageInfo` fields
      def page_info
        self
      end

      # @return [Boolean] True if there are more items after this page
      def has_next_page
        raise PaginationImplementationMissingError, "Implement #{self.class}#has_next_page to return the next-page check"
      end

      # @return [Boolean] True if there were items before these items
      def has_previous_page
        raise PaginationImplementationMissingError, "Implement #{self.class}#has_previous_page to return the previous-page check"
      end

      # @return [String] The cursor of the first item in {nodes}
      def start_cursor
        nodes.first && cursor_for(nodes.first)
      end

      # @return [String] The cursor of the last item in {nodes}
      def end_cursor
        nodes.last && cursor_for(nodes.last)
      end

      # Return a cursor for this item.
      # @param item [Object] one of the passed in {items}, taken from {nodes}
      # @return [String]
      def cursor_for(item)
        raise PaginationImplementationMissingError, "Implement #{self.class}#cursor_for(item) to return the cursor for #{item.inspect}"
      end

      private

      # @param argument [nil, Integer] `first` or `last`, as provided by the client
      # @param max_page_size [nil, Integer]
      # @return [nil, Integer] `nil` if the input was `nil`, otherwise a value between `0` and `max_page_size`
      def limit_pagination_argument(argument, max_page_size)
        if argument
          if argument < 0
            argument = 0
          elsif max_page_size && argument > max_page_size
            argument = max_page_size
          end
        end
        argument
      end

      def decode(cursor)
        context.schema.cursor_encoder.decode(cursor, nonce: true)
      end

      def encode(cursor)
        context.schema.cursor_encoder.encode(cursor, nonce: true)
      end

      # A wrapper around paginated items. It includes a {cursor} for pagination
      # and could be extended with custom relationship-level data.
      class Edge
        attr_reader :node

        def initialize(node, connection)
          @connection = connection
          @node = node
        end

        def parent
          @connection.parent
        end

        def cursor
          @cursor ||= @connection.cursor_for(@node)
        end
      end
    end
  end
end