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 4 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
133 changes: 115 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: :fetch_resources!

# The array of actual elements returned by index actions
attr_accessor :elements, :resource_class, :original_params
attr_accessor :elements, :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,138 @@ 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
# 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

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
fetch_resources!
self
end
alias map! collect!

# 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

# Fetches 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 fetch_resources!
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
[]
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
44 changes: 19 additions & 25 deletions test/cases/collection_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,6 @@ def setup
end

class BasicCollectionTest < CollectionTest
def test_collection_respond_to_collect!
assert @collection.respond_to?(:collect!)
end

def test_collection_respond_to_map!
assert @collection.respond_to?(:map!)
end
Comment on lines -12 to -18
Copy link
Author

Choose a reason for hiding this comment

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

these methods were removed


def test_collection_respond_to_first_or_create
assert @collection.respond_to?(:first_or_create)
end
Expand All @@ -33,27 +25,14 @@ def test_first_or_initialize_without_resource_class_raises_error
assert_raise(RuntimeError) { @collection.first_or_initialize }
end

def test_collect_bang_modifies_elements
elements = %w(a b c)
@collection.elements = elements
results = @collection.collect! { |i| i + "!" }
assert_equal results.to_a, elements.collect! { |i| i + "!" }
end

def test_collect_bang_returns_collection
@collection.elements = %w(a)
results = @collection.collect! { |i| i + "!" }
assert_kind_of ActiveResource::Collection, results
end
Comment on lines -36 to -47
Copy link
Author

Choose a reason for hiding this comment

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

these methods were removed


def respond_to_where
assert @collection.respond_to?(:where)
end
end

class PaginatedCollection < ActiveResource::Collection
attr_accessor :next_page
def initialize(parsed = {})
def parse_response(parsed)
@elements = parsed["results"]
@next_page = parsed["next_page"]
end
Expand All @@ -73,6 +52,7 @@ class ReduxCollection < ActiveResource::Base
class CollectionInheritanceTest < ActiveSupport::TestCase
def setup
@post = { id: 1, title: "Awesome" }
@post_even_more = { id: 1, title: "Awesome", subtitle: "EvenMore" }
@posts_hash = { "results" => [@post], :next_page => "/paginated_posts.json?page=2" }
@posts = @posts_hash.to_json
@posts2 = { "results" => [@post.merge(id: 2)], :next_page => nil }.to_json
Expand All @@ -85,6 +65,7 @@ def setup
mock.get "/paginated_posts.json?page=2", {}, @posts
mock.get "/paginated_posts.json?title=test", {}, @empty_posts
mock.get "/paginated_posts.json?page=2&title=Awesome", {}, @posts
mock.get "/paginated_posts.json?subtitle=EvenMore&title=Awesome", {}, @posts
mock.post "/paginated_posts.json", {}, nil
end
end
Expand All @@ -97,12 +78,12 @@ def test_setting_collection_parser_resource_class
assert_equal PaginatedPost, PaginatedPost.where(page: 2).resource_class
end

def test_setting_collection_parser_original_params
assert_equal({ page: 2 }, PaginatedPost.where(page: 2).original_params)
def test_setting_collection_parser_query_params
assert_equal({ page: 2 }, PaginatedPost.where(page: 2).query_params)
end

def test_custom_accessor
assert_equal PaginatedPost.find(:all).next_page, @posts_hash[:next_page]
assert_equal PaginatedPost.find(:all).call.next_page, @posts_hash[:next_page]
end

def test_first_or_create
Expand All @@ -120,4 +101,17 @@ def test_where
next_posts = posts.where(title: "Awesome")
assert_kind_of PaginatedCollection, next_posts
end

def test_where_lazy_chain
expected_request = ActiveResource::Request.new(
:get,
"/paginated_posts.json?subtitle=EvenMore&title=Awesome",
nil,
{ "Accept" => "application/json" }
)
posts = PaginatedPost.where(title: "Awesome").where(subtitle: "EvenMore")
assert_equal 0, ActiveResource::HttpMock.requests.count { |r| r == expected_request }
posts.to_a
assert_equal 1, ActiveResource::HttpMock.requests.count { |r| r == expected_request }
end
end
Loading