Skip to content

[ISSUE-408] Lazy Network Calls on Collections #409

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

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/active_resource/associations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def defines_has_many_finder_method(reflection)
elsif !new_record?
instance_variable_set(ivar_name, reflection.klass.find(:all, params: { "#{self.class.element_name}_id": self.id }))
else
instance_variable_set(ivar_name, self.class.collection_parser.new)
instance_variable_set(ivar_name, reflection.klass.find(:all))
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this is now lazy, no network call is actually made.

end
end
end
Expand Down
57 changes: 33 additions & 24 deletions lib/active_resource/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,33 @@ def format_extension
include_format_in_path ? ".#{format.extension}" : ""
end

# Instantiates a new record with the given options.
#
# This method creates a new instance of the class with the provided record and sets its `prefix_options` attribute.
#
# ==== Options
#
# +record+ [Object] The record to be instantiated.
# +prefix_options+ [Hash, nil] Optional hash containing prefix options for the resource. Defaults to an empty hash.
#
# ==== Returns
#
# [Object] The newly instantiated resource.
#
# ==== Examples
#
# MyResource.instantiate_record(record)
# # Creates a new MyResource instance with default prefix options.
#
# MyResource.instantiate_record(record, { prefix: "admin" })
# # Creates a new MyResource instance with prefix set to "admin".
#
def instantiate_record(record, prefix_options = {})
new(record, true).tap do |resource|
resource.prefix_options = prefix_options
end
end
Comment on lines +800 to +804
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed from private to public


# Gets the element path for the given ID in +id+. If the +query_options+ parameter is omitted, Rails
# will split from the \prefix options.
#
Expand Down Expand Up @@ -1096,23 +1123,12 @@ def find_every(options)
params = options[:params]
prefix_options, query_options = split_options(params)

response =
case from = options[:from]
when Symbol
get(from, params)
when String
path = "#{from}#{query_string(query_options)}"
format.decode(connection.get(path, headers).body)
else
path = collection_path(prefix_options, query_options)
format.decode(connection.get(path, headers).body)
end

instantiate_collection(response || [], query_options, prefix_options)
rescue ActiveResource::ResourceNotFound
# Swallowing ResourceNotFound exceptions and return nil - as per
# ActiveRecord.
nil
collection_parser.new([], options[:from]).tap do |parser|
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Network call now gets made within the ActiveResource::Collection class, so this is simply initializing it.

parser.resource_class = self
parser.query_params = query_options
parser.prefix_options = prefix_options
parser.path_params = params
end
end

# Find a single resource from a one-off URL
Expand Down Expand Up @@ -1140,13 +1156,6 @@ def instantiate_collection(collection, original_params = {}, prefix_options = {}
end.collect! { |record| instantiate_record(record, prefix_options) }
end

def instantiate_record(record, prefix_options = {})
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made public

new(record, true).tap do |resource|
resource.prefix_options = prefix_options
end
end


# Accepts a URI and creates the site URI from that.
def create_site_uri_from(site)
site.is_a?(URI) ? site.dup : URI.parse(site)
Expand Down
156 changes: 138 additions & 18 deletions lib/active_resource/collection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@

module ActiveResource # :nodoc:
class Collection # :nodoc:
SELF_DEFINE_METHODS = [:to_a, :collect!, :map!, :all?]
include Enumerable
delegate :to_yaml, :all?, *(Array.instance_methods(false) - SELF_DEFINE_METHODS), to: :to_a
delegate :to_yaml, *Array.public_instance_methods(false), to: :request_resources!

# The array of actual elements returned by index actions
attr_accessor :elements, :resource_class, :original_params
attr_accessor :resource_class, :query_params, :path_params
attr_writer :prefix_options
attr_reader :from

# ActiveResource::Collection is a wrapper to handle parsing index responses that
# do not directly map to Rails conventions.
Expand Down Expand Up @@ -41,7 +41,7 @@ class Collection # :nodoc:
#
# class PostCollection < ActiveResource::Collection
# attr_accessor :next_page
# def initialize(parsed = {})
# def parse_response(parsed = {})
# @elements = parsed['posts']
# @next_page = parsed['next_page']
# end
Expand All @@ -54,41 +54,161 @@ class Collection # :nodoc:
# @posts.next_page # => "/posts.json?page=2"
# @posts.map(&:id) # =>[1, 3, 5 ...]
#
# The initialize method will receive the ActiveResource::Formats parsed result
# The ActiveResource::Collection#parse_response method will receive the ActiveResource::Formats parsed result
# and should set @elements.
def initialize(elements = [])
def initialize(elements = [], from = nil)
@from = from
@elements = elements
@requested = false
# This can get called without a response, so parse only if response is present
parse_response(@elements) if @elements.present?
end

def to_a
elements
# Processes and sets the collection elements. This method assigns the provided `elements`
# (or an empty array if none provided) to the `@elements` instance variable.
#
# ==== Arguments
#
# +elements+ (Array<Object>) - An optional array of resources to be set as the collection elements.
# Defaults to an empty array.
#
# This method is called after fetching the resource and can be overridden by subclasses to
# handle any specific response format of the API.
def parse_response(elements)
@elements = elements || []
end

# Returns the prefix options for the collection, which are used for constructing the resource path.
#
# ==== Returns
#
# [Hash] The prefix options for the collection.
def prefix_options
@prefix_options || {}
end

# Refreshes the collection by re-fetching the resources from the API.
#
# ==== Returns
#
# [Array<Object>] The collection of resources retrieved from the API.
def refresh
@requested = false
request_resources!
end

def collect!
return elements unless block_given?
set = []
each { |o| set << yield(o) }
@elements = set
# Executes the request to fetch the collection of resources from the API and returns the collection.
#
# ==== Returns
#
# [ActiveResource::Collection] The collection of resources.
def call
request_resources!
self
end
alias map! collect!

# Checks if the collection has been requested.
#
# ==== Returns
#
# [Boolean] true if the collection has been requested, false otherwise.
def requested?
@requested
end

# Returns the first resource in the collection, or creates a new resource using the provided
# attributes if the collection is empty.
#
# ==== Arguments
#
# +attributes+ (Hash) - The attributes for creating the resource.
#
# ==== Returns
#
# [Object] The first resource, or a newly created resource if none exist.
#
# ==== Example
# post = PostCollection.where(title: "New Post").first_or_create
# # => Post instance with title "New Post"
def first_or_create(attributes = {})
first || resource_class.create(original_params.update(attributes))
first || resource_class.create(query_params.update(attributes))
rescue NoMethodError
raise "Cannot create resource from resource type: #{resource_class.inspect}"
end

# Returns the first resource in the collection, or initializes a new resource using the provided
# attributes if the collection is empty.
#
# ==== Arguments
#
# +attributes+ (Hash) - The attributes for initializing the resource.
#
# ==== Returns
#
# [Object] The first resource, or a newly initialized resource if none exist.
#
# ==== Example
# post = PostCollection.where(title: "New Post").first_or_initialize
# # => Post instance with title "New Post"
def first_or_initialize(attributes = {})
first || resource_class.new(original_params.update(attributes))
first || resource_class.new(query_params.update(attributes))
rescue NoMethodError
raise "Cannot build resource from resource type: #{resource_class.inspect}"
end

# Filters the collection based on the provided clauses (query parameters).
#
# ==== Arguments
#
# +clauses+ (Hash) - A hash of query parameters used to filter the collection.
#
# ==== Returns
#
# [ActiveResource::Collection] A new collection filtered by the specified clauses.
#
# ==== Example
# filtered_posts = PostCollection.where(title: "Post 1")
# # => PostCollection:xxx (filtered collection)
def where(clauses = {})
raise ArgumentError, "expected a clauses Hash, got #{clauses.inspect}" unless clauses.is_a? Hash
new_clauses = original_params.merge(clauses)
new_clauses = query_params.merge(clauses)
resource_class.where(new_clauses)
end

private
def query_string(options)
"?#{options.to_query}" unless options.nil? || options.empty?
end

# Requests resources from the API and parses the response. The resources are then mapped to their respective
# resource class instances.
#
# ==== Returns
#
# [Array<Object>] The collection of resources retrieved from the API.
def request_resources!
return @elements if requested?
response =
case from
when Symbol
resource_class.get(from, path_params)
when String
path = "#{from}#{query_string(query_params)}"
resource_class.format.decode(resource_class.connection.get(path, resource_class.headers).body)
else
path = resource_class.collection_path(prefix_options, query_params)
resource_class.format.decode(resource_class.connection.get(path, resource_class.headers).body)
end

# Update the elements
parse_response(response)
@elements.map! { |e| resource_class.instantiate_record(e, prefix_options) }
rescue ActiveResource::ResourceNotFound
# Swallowing ResourceNotFound exceptions and return nothing - as per ActiveRecord.
# Needs to be empty array as Array methods are delegated
[]
ensure
@requested = true
end
end
end
4 changes: 2 additions & 2 deletions test/cases/association_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ def test_has_many

def test_has_many_on_new_record
Post.send(:has_many, :topics)
Topic.stubs(:find).returns([:unexpected_response])
assert_equal [], Post.new.topics.to_a

assert_kind_of ActiveResource::Collection, Post.new.topics
end

def test_has_one
Expand Down
Loading
Loading