Class: X::Cursor

Inherits:
Object
  • Object
show all
Includes:
Enumerable
Defined in:
x-objects/lib/x/objects/cursor.rb

Overview

A lazily paginated, cached, thread-safe collection of resources

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#client ⇒ Object (readonly)

The client the resources hold, which also fetches the pages

The pages of an endpoint that refuses OAuth 1.0a, such as the posts of a space, are fetched with the app-only client of a client that signs with it, while the resources hold the client itself.

Examples:

Get the client

cursor.client

Returns:

  • (Object) —

    the client



39
40
41
# File 'x-objects/lib/x/objects/cursor.rb', line 39

def client
  @client
end

#resource_class ⇒ Class (readonly)

The class of the resources in this collection

Examples:

Get the resource class

user.followers.resource_class # => X::User

Returns:

  • (Class) —

    the resource class



28
29
30
# File 'x-objects/lib/x/objects/cursor.rb', line 28

def resource_class
  @resource_class
end

Instance Method Details

#any?(*pattern) {|Resource| ... } ⇒ Boolean

Check whether the collection holds any resource, requesting one

Without a pattern or a block, this asks for a single resource, raised to the endpoint's minimum, rather than a full page.

Examples:

Check whether a user has any followers

user.followers.any?

Parameters:

  • pattern (Object) —

    a pattern each resource is matched against

Yields:

Returns:

  • (Boolean) —

    true if any resource matches



229
230
231
232
233
# File 'x-objects/lib/x/objects/cursor.rb', line 229

def any?(*pattern, &block)
  return super unless pattern.empty? && block.nil?

  !first.nil?
end

#as_json ⇒ void

This method returns an undefined value.

Refuse to write the collection as JSON, which would read every page of it

Serializing a cursor would read every page of the collection, a request per page, and the API bills each resource it returns, from a call that says nothing of it, such as a cursor in a Hash that a log or a render writes. It raises instead, as ActiveSupport would otherwise read a cursor as the Enumerable it is. Serialize what first(n) or to_a reads instead, each of which says at the call how much it reads.

Examples:

Serialize the first ten followers rather than every one of them

user.followers.first(10).as_json

Raises:



317
# File 'x-objects/lib/x/objects/cursor.rb', line 317

def as_json(*) = raise(UnsupportedOperation, SERIALIZATION_MESSAGE)

#each {|Resource| ... } ⇒ Enumerator, Cursor

Iterate over every resource, fetching pages as needed

Examples:

Print every follower

user.followers.each { |follower| puts follower.username }

Yields:

Returns:

  • (Enumerator, Cursor) —

    an enumerator without a block, otherwise self



88
89
90
91
92
# File 'x-objects/lib/x/objects/cursor.rb', line 88

def each(&block)
  return to_enum unless block

  each_page { |page| page.each(&block) }
end

#each_page {|Page| ... } ⇒ Enumerator, Cursor

Iterate over every page, fetching pages as needed

The pages are those #page reads, as they were fetched, so a page need not hold as many resources as the largest page the endpoint allows.

Examples:

Print the size of every page

user.followers.each_page { |page| puts page.result_count }

Yields:

  • (Page) —

    each page

Returns:

  • (Enumerator, Cursor) —

    an enumerator without a block, otherwise self



104
105
106
107
108
109
110
111
112
113
# File 'x-objects/lib/x/objects/cursor.rb', line 104

def each_page
  return to_enum(:each_page) unless block_given?

  index = 0
  while (current = page(index))
    yield current
    index += 1
  end
  self
end

#empty? ⇒ Boolean

Check whether the collection is empty, requesting one resource

Like none? without a pattern or a block, this asks for a single resource, raised to the endpoint's minimum, rather than a full page.

Examples:

Check whether a user has no followers

user.followers.empty?

Returns:

  • (Boolean) —

    true if the collection holds no resource



261
# File 'x-objects/lib/x/objects/cursor.rb', line 261

def empty? = first.nil?

#encode_with(_coder) ⇒ void

This method returns an undefined value.

Refuse to write the cursor as YAML, as it refuses Marshal

YAML reads no marshal_dump, and would write every instance variable of the cursor, its client and the credentials it holds among them, so it raises as #marshal_dump does. Write what first(n) or to_a reads, or a page, instead.

Examples:

Write the first page of the followers of a user as YAML rather than the cursor

YAML.dump(user.followers.page(0))

Parameters:

  • _coder (Psych::Coder) —

    the coder YAML would write the cursor with

Raises:

  • (TypeError) —

    always



371
# File 'x-objects/lib/x/objects/cursor.rb', line 371

def encode_with(_coder) = raise(TypeError, SERIALIZATION_MESSAGE)

#first(count = nil) ⇒ Resource, ...

The first resource, or the first few, requesting pages no larger than needed

Iterating a cursor requests the largest page an endpoint allows, which costs the least in requests. The API bills each resource returned, so first asks for a page of the size it needs instead, raised to the endpoint's minimum, and each page after the first asks for no more than the pages before it left. The API may serve an empty page with the token of the next, having left out what it filters, such as suspended users, so first reads on until it finds a resource. The cursor keeps the pages first reads, as it keeps every page, so a cursor whose pages already hold what is asked for answers from them, without a request, and an iteration after first requests only what first left.

Examples:

Read ten followers in one request for ten users

user.followers.first(10)

Parameters:

  • count (Integer, nil) (defaults to: nil) —

    the number of resources, or nil for the first resource alone; a Float is read as the Integer it converts to, as Array#first reads it

Returns:

  • (Resource, Array<Resource>, nil) —

    the first resource, or the first resources, frozen

Raises:

  • (ArgumentError) —

    if the count is negative

  • (TypeError) —

    if the count is not a number that converts to an Integer



185
186
187
188
189
190
191
# File 'x-objects/lib/x/objects/cursor.rb', line 185

def first(count = nil)
  resources = @pages.read(count.nil? ? 1 : Utils.count!(count)) #: Array[untyped]
  return resources unless count.nil?

  resource, = resources
  resource
end

#ids ⇒ Array<Integer, String>

The identifiers of every resource, requesting nothing but identifiers

Examples:

Get the identifiers of every follower

user.followers.ids

Returns:

  • (Array<Integer, String>) —

    the identifiers, Integers unless the resource's identifiers are not numbers, frozen

Raises:



303
# File 'x-objects/lib/x/objects/cursor.rb', line 303

def ids = stubs.map(&:id).freeze

#inspect ⇒ String

Summarize the cursor for the console

Examples:

Inspect a cursor

user.followers.inspect # => #<X::Cursor resource_class=X::User path="users/7505382/followers">

Returns:

  • (String) —

    the class name, resource class, and path



379
# File 'x-objects/lib/x/objects/cursor.rb', line 379

def inspect = "#<#{self.class} resource_class=#{resource_class} path=#{path.inspect}>"

#marshal_dump ⇒ void

This method returns an undefined value.

Refuse to write the cursor with Marshal, as it refuses to write it as JSON

A cursor holds its client, and the threads and locks that fetch its pages, none of which Marshal can write, and caching the collection it names would mean reading every page of it, as #as_json says. It raises the TypeError Marshal raises for what it cannot write, with the message of #as_json, rather than the one Marshal would raise from within the cursor. Marshal what first(n) or to_a reads, or a page, instead.

Examples:

Cache the first page of the followers of a user rather than the cursor

Rails.cache.write("followers", user.followers.page(0))

Raises:

  • (TypeError) —

    always



357
# File 'x-objects/lib/x/objects/cursor.rb', line 357

def marshal_dump = raise(TypeError, SERIALIZATION_MESSAGE)

#none?(*pattern) {|Resource| ... } ⇒ Boolean

Check whether the collection holds no resource, requesting one

Without a pattern or a block, this asks for a single resource, raised to the endpoint's minimum, rather than a full page.

Examples:

Check whether a user follows nobody

user.following.none?

Parameters:

  • pattern (Object) —

    a pattern each resource is matched against

Yields:

Returns:

  • (Boolean) —

    true if no resource matches



246
247
248
249
250
# File 'x-objects/lib/x/objects/cursor.rb', line 246

def none?(*pattern, &block)
  return super unless pattern.empty? && block.nil?

  first.nil?
end

#one?(*pattern) {|Resource| ... } ⇒ Boolean

Check whether the collection holds one resource, requesting two

Without a pattern or a block, this asks for two resources, raised to the endpoint's minimum, rather than a full page.

Examples:

Check whether a list has a single member

list.members.one?

Parameters:

  • pattern (Object) —

    a pattern each resource is matched against

Yields:

Returns:

  • (Boolean) —

    true if exactly one resource matches



274
275
276
277
278
# File 'x-objects/lib/x/objects/cursor.rb', line 274

def one?(*pattern, &block)
  return super unless pattern.empty? && block.nil?

  take(2).size.eql?(1)
end

#page(index) ⇒ Page?

Fetch a page by index, using the cache when possible

The pages before the one asked for are read first, since the token of each asks for the next. A page is the page as it was fetched and kept, whatever fetched it: iterating fetches the largest page the endpoint allows, but first, take, any?, and empty? fetch pages no larger than they need, which the cursor keeps too, so that an iteration after them does not pay again for what they read. After user.followers.first, the first page holds one follower, and the pages an iteration fetches after it as many as the largest page does. The API may serve a page with fewer resources than it was asked for, or none, so no page has a size to rely on.

Examples:

Fetch the first page

user.followers.page(0)

Parameters:

  • index (Integer) —

    the zero-based page index

Returns:

  • (Page, nil) —

    the page or nil if the collection has fewer pages

Raises:

  • (TypeError) —

    if the index is not an Integer, such as the String "1" or the Float 1.5, which name no page

  • (ArgumentError) —

    if the index is negative, since pages are read forward from the first



131
# File 'x-objects/lib/x/objects/cursor.rb', line 131

def page(index) = @pages.at(index)

#prefetch ⇒ Cursor

Return a new cursor over the same collection with prefetching enabled

A page the background thread fails to fetch is not requested again when it is reached: the error the thread failed with is raised there, once, and a page asked for again after it is requested again.

Examples:

Fetch every follower while overlapping requests with processing

user.followers.prefetch.each { |follower| process(follower) }

Returns:



153
# File 'x-objects/lib/x/objects/cursor.rb', line 153

def prefetch = self.class.__send__(:build, resource_class, path, client:, params: own_params, prefetch: true, token_param:, min_results:, app_only: app_only?, total: @total, ids_only: ids_only?)

#prefetch? ⇒ Boolean

Check whether the next page is fetched in the background

Examples:

Check whether a cursor prefetches

cursor.prefetch? # => false

Returns:

  • (Boolean) —

    true if pages are prefetched



79
# File 'x-objects/lib/x/objects/cursor.rb', line 79

def prefetch? = @prefetch

#published_count ⇒ Integer?

The number the API publishes for the collection, without reading any of it

The API publishes a number for a user's followers, followed users, and list memberships, and for a list's members and followers. Reading it costs no request when the user or list holds it, and one lookup when it is a stub. The number counts what the collection holds, which can differ from what count reads, since the endpoint leaves out what the authenticated user cannot see, such as private lists and suspended users. count instead reads every page of the collection, a request per page, and the API bills each resource. A cursor answers no size, so that Ruby's own methods, such as each_slice and lazy, do not page a collection to size it.

Examples:

Count a user's followers without reading one of them

user.followers.published_count # => 12345

Returns:

  • (Integer, nil) —

    the published number, or nil for a collection the API publishes no number for



293
# File 'x-objects/lib/x/objects/cursor.rb', line 293

def published_count = @total&.call

#refresh ⇒ Cursor

Return a new cursor over the same collection with an empty page cache

The number the API publishes for the collection is read again too, once, the first time published_count asks for it, since the collection it counts may have changed.

Examples:

Iterate again with fresh data

followers = user.followers.refresh

Returns:



142
# File 'x-objects/lib/x/objects/cursor.rb', line 142

def refresh = self.class.__send__(:build, resource_class, path, client:, params: own_params, prefetch: prefetch?, token_param:, min_results:, app_only: app_only?, total: fresh_total, ids_only: ids_only?)

#stubs ⇒ Cursor

Return a new cursor over the same collection that yields stubs

The requests ask for nothing but identifiers, and each resource is a stub holding only its identifier, which hydrates on demand, even when the API returns a few default fields alongside it.

Examples:

Check whether a user is among thousands of followers without fetching their fields

user.followers.stubs.any?(other)

Returns:

Raises:



165
# File 'x-objects/lib/x/objects/cursor.rb', line 165

def stubs = self.class.__send__(:build, resource_class, path, client:, params: id_only_params, prefetch: prefetch?, token_param:, min_results:, app_only: app_only?, total: @total, ids_only: true)

#take(count) ⇒ Array<Resource>

The first few resources, requesting pages no larger than needed, as first does

Examples:

Read three followers in one request for three users

user.followers.take(3)

Parameters:

  • count (Integer) —

    the number of resources; a Float is read as the Integer it converts to, as Array#take reads it

Returns:

  • (Array<Resource>) —

    the first resources, frozen

Raises:

  • (ArgumentError) —

    if the count is negative

  • (TypeError) —

    if the count is not a number that converts to an Integer, such as nil or a String



216
# File 'x-objects/lib/x/objects/cursor.rb', line 216

def take(count) = first(Utils.count!(count))

#to_a ⇒ Array<Resource> Also known as: entries

Every resource, fetching every page

The array is frozen, as the arrays first and take return are, since a cursor keeps the pages it read and a caller that changed what it returned would change nothing the cursor holds.

Examples:

Read every follower

user.followers.to_a

Returns:

  • (Array<Resource>) —

    every resource, frozen



202
# File 'x-objects/lib/x/objects/cursor.rb', line 202

def to_a = super.freeze

#to_h {|resource| ... } ⇒ Hash

A Hash of the pair a block returns for each resource, reading every page

Without a block it raises as #as_json does, before it reads a page, since a page of the collection is read as a Hash only by its as_json, rather than raise TypeError from the to_h of Enumerable once it has read one.

Examples:

Index the members of a list by username

list.members.to_h { |user| [user.username, user] }

Yield Parameters:

Yield Returns:

  • (Array(Object, Object)) —

    the key and value of the resource

Returns:

  • (Hash) —

    the pairs the block returns

Raises:



331
# File 'x-objects/lib/x/objects/cursor.rb', line 331

def to_h(&block) = block ? super() : raise(UnsupportedOperation, SERIALIZATION_MESSAGE)

#to_json(_state = nil) ⇒ void

This method returns an undefined value.

Refuse to write the collection as a JSON array, which would read every page

It raises as #as_json does, for the reason that says.

Examples:

Serialize a whole collection, reading every page of it

list.members.to_a.to_json # => "[{\"id\":\"7505382\"}]"

Parameters:

  • _state (JSON::State, nil) (defaults to: nil) —

    the state a JSON encoder passes

Raises:



343
# File 'x-objects/lib/x/objects/cursor.rb', line 343

def to_json(_state = nil) = raise(UnsupportedOperation, SERIALIZATION_MESSAGE)