Skip to content

Commit 10c7344

Browse files
committed
where: Support String arguments
The problem --- When a resource queries an "index"-like endpoint and receives a response payload that relies on URL-based pagination, it can be tedious to deconstruct the next page's URL and transform it into a subsequent `.where` call. For example, consider a `Post` payload like: ```json { "posts": { … }, "next_page": "https://api.blog.com/posts?page=2" } ``` In order to query the URL in the `next_page` property, the collection parser must extract a `{ "page" => "2" }` Hash and forward it to a `resource_class.where` call: ```ruby def initialize(parsed = {}) @elements = parsed["posts"] @next_page_uri = URI.parse(parsed["next_page"]) end def next_page_params query_string = @next_page_uri.query URI.decode_www_form(query_string).to_h end def next_page resource_class.where(next_page_params) end collection.next_page_params # => { "page" => "2" } collection.next_page # => GET https://api.blog.com/posts?page=2 ``` The process becomes complicated when there are other parameters (including "array"-like keys): ```json { "posts": { … }, "next_page": "https://api.blog.com/posts?tags[]=Ruby&tags[]=Rails&page=2" } ``` In this scenario, the Array created by [URI.decode_www_form][] will only retain both `tags[]`-keyed values, but the [Array#to_h][] call will flatten that Array into a Hash that only contains the last key. In this case, `tags[]=Ruby` will be omitted, and the resulting Hash would be `{ "tags[]" => "Rails", "page" => "2" }`. The proposal --- Active Record's `.where` method supports both [String][] and [Array][] arguments. In the context of Active Record, `String` and `Array` arguments are in support of the underlying SQL queries to be executed. In the context of Active Resource, the underlying format for a "query" is an HTTP-compliant query that's encoded as an [application/x-www-form-urlencoded][] string. This commit proposes adding support to `Base.where` to accept both String and Array arguments. This support would simplify the scenario above: ```ruby def initialize(parsed = {}) @elements = parsed["posts"] @next_page_uri = URI.parse(parsed["next_page"]) end def next_page resource_class.where(@next_page_uri.query) end collection.next_page # => GET https://api.blog.com/posts?page=2 ``` When Active Resource is loaded alongside a Rails application, rely on [ActionDispatch::ParamBuilder.from_pairs][] to decode key-value pairs. Otherwise, rely on [Array#to_h][]. [URI.decode_www_form]: https://docs.ruby-lang.org/en/master/URI.html#method-c-decode_www_form [String]: https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-where-label-String [Array]: https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-where-label-Array [application/x-www-form-urlencoded]: https://url.spec.whatwg.org/#application/x-www-form-urlencoded [ActionDispatch::ParamBuilder.from_pairs]: https://api.rubyonrails.org/classes/ActionDispatch/ParamBuilder.html#method-i-from_pairs [Array#to_h]: https://docs.ruby-lang.org/en/master/Array.html#method-i-to_h
1 parent 4fffccc commit 10c7344

File tree

8 files changed

+215
-0
lines changed

8 files changed

+215
-0
lines changed

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ source "https://rubygems.org"
55
git_source(:github) { |repo| "https://github.com/#{repo}" }
66

77
branch = ENV.fetch("BRANCH", "main")
8+
gem "actionpack", github: "rails/rails", branch: branch
89
gem "activesupport", github: "rails/rails", branch: branch
910
gem "activemodel", github: "rails/rails", branch: branch
1011
gem "activejob", github: "rails/rails", branch: branch
@@ -18,6 +19,7 @@ gem "rubocop-performance"
1819
gem "rubocop-rails"
1920
gem "rubocop-rails-omakase"
2021

22+
gem "minitest", "< 6"
2123
gem "minitest-bisect"
2224

2325
gemspec

lib/active_resource/base.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,7 +1151,52 @@ def all(*args)
11511151

11521152
# This is an alias for all. You can pass in all the same
11531153
# arguments to this method as you can to <tt>all</tt> and <tt>find(:all)</tt>
1154+
#
1155+
# #where accepts conditions in one of several formats. In the examples below, the resulting
1156+
# URL is given as an illustration.
1157+
#
1158+
# === \String
1159+
#
1160+
# A string is passed as URL query parameters.
1161+
#
1162+
# Person.where("name=Matz")
1163+
# # https://api.people.com/people.json?name=Matz
1164+
#
1165+
# === \Array
1166+
#
1167+
# If an array is passed, the elements of the array are treated as
1168+
# name-value pairs. Active Resource takes care of building the query string.
1169+
# Elements are inserted into the string in the order in which they appear.
1170+
#
1171+
# Person.where([ ["name", "Matz"] ])
1172+
# # https://api.people.com/people.json?name=Matz
1173+
#
1174+
# When Rails', Action Dispatch's parameter builder support is available
1175+
# for more complex pairs:
1176+
#
1177+
# Article.where([ ["tags[]", "Ruby"], ["tags[]", "Rails"] ])
1178+
# # https://api.people.com/people.json?tags[]=Ruby&tags[]=Rails
1179+
#
1180+
# Person.where([ ["person[name]", "Matz"] ])
1181+
# # https://api.people.com/people.json?person[name]=Matz
1182+
#
1183+
# === \Hash
1184+
#
1185+
# #where will also accept a hash condition, in which the keys are fields and the values
1186+
# are values to be searched for.
1187+
#
1188+
# Fields can be symbols or strings. Values can be single values, arrays, or ranges.
1189+
#
1190+
# Person.where(name: "Matz")
1191+
# # https://api.people.com/people.json?name=Matz
1192+
#
1193+
# Person.where(person: { name: "Matz" })
1194+
# # https://api.people.com/people.json?person[name]=Matz
1195+
#
1196+
# Article.where(tags: ["Ruby", "Rails"])
1197+
# # https://api.people.com/people.json?tags[]=Ruby&tags[]=Rails
11541198
def where(clauses = {})
1199+
clauses = query_format.decode(clauses) if clauses.is_a?(String) || clauses.is_a?(Array)
11551200
clauses = sanitize_forbidden_attributes(clauses)
11561201
raise ArgumentError, "expected a clauses Hash, got #{clauses.inspect}" unless clauses.is_a? Hash
11571202
all(params: clauses)

lib/active_resource/formats.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def self.[](mime_type_reference)
1414
case mime_type_reference.to_s
1515
when "xml" then XmlFormat
1616
when "json" then JsonFormat
17+
when "url_encoded_form" then UrlEncodedFormat
1718
else ActiveResource::Formats.const_get(ActiveSupport::Inflector.camelize(mime_type_reference.to_s) + "Format")
1819
end
1920
end

lib/active_resource/formats/url_encoded_format.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,30 @@ module Formats
77
module UrlEncodedFormat
88
extend self
99

10+
# An object that responds to +#from_pairs+ that transforms an Array of
11+
# name value pairs into a params Hash.
12+
#
13+
# Defaults to calling Array#to_h when ActionDispatch::ParamBuilder is unavailable.
14+
mattr_accessor :param_builder, default: self
15+
16+
def mime_type
17+
"application/x-www-form-urlencoded"
18+
end
19+
1020
# URL encode the parameters Hash
1121
def encode(params, options = nil)
1222
params.to_query
1323
end
24+
25+
# URL decode the query string
26+
def decode(query, remove_root = true)
27+
query = URI.decode_www_form(query) if query.is_a?(String)
28+
param_builder.from_pairs(query)
29+
end
30+
31+
def from_pairs(array) # :nodoc:
32+
array.to_h
33+
end
1434
end
1535
end
1636
end

lib/active_resource/railtie.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ class Railtie < Rails::Railtie
3434
ActiveSupport.on_load(:active_resource) { self.logger ||= ::Rails.logger }
3535
end
3636

37+
initializer "active_resource.url_encoded_form_format" do
38+
ActiveSupport.on_load(:action_controller) do
39+
require "action_dispatch/http/param_builder"
40+
41+
ActiveResource::Formats[:url_encoded_form].param_builder = ActionDispatch::ParamBuilder
42+
end
43+
end
44+
3745
initializer "active_resource.http_mock" do
3846
ActiveSupport.on_load(:active_support_test_case) do
3947
teardown { ActiveResource::HttpMock.reset! }

test/cases/finder_test.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require "fixtures/proxy"
99
require "fixtures/pet"
1010
require "active_support/core_ext/hash/conversions"
11+
require "action_dispatch/http/param_builder"
1112

1213
module CamelcaseUrlEncodedFormat
1314
extend ActiveResource::Formats::UrlEncodedFormat
@@ -178,6 +179,54 @@ def test_where_clause_with_permitted_params
178179
assert_kind_of StreetAddress, addresses.first
179180
end
180181

182+
def test_where_clause_string
183+
query = URI.encode_www_form([ [ "id", "1" ] ])
184+
ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?" + query, {}, @people_david }
185+
people = Person.where(query)
186+
assert_equal 1, people.size
187+
assert_kind_of Person, people.first
188+
assert_equal "David", people.first.name
189+
end
190+
191+
def test_where_clause_string_with_multiple_params
192+
previous_param_builder = ActiveResource::Formats::UrlEncodedFormat.param_builder
193+
ActiveResource::Formats::UrlEncodedFormat.param_builder = ActionDispatch::ParamBuilder
194+
195+
query = URI.encode_www_form([ [ "id[]", "1" ], [ "id[]", "2" ] ])
196+
ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?" + query, {}, @people }
197+
people = Person.where(query)
198+
assert_equal 2, people.size
199+
assert_kind_of Person, people.first
200+
assert_kind_of Person, people.second
201+
assert_equal [ "Matz", "David" ], people.map(&:name)
202+
ensure
203+
ActiveResource::Formats::UrlEncodedFormat.param_builder = previous_param_builder
204+
end
205+
206+
def test_where_clause_array
207+
ids = [ [ "id", "1" ] ]
208+
ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?" + URI.encode_www_form(ids), {}, @people_david }
209+
people = Person.where(ids)
210+
assert_equal 1, people.size
211+
assert_kind_of Person, people.first
212+
assert_equal "David", people.first.name
213+
end
214+
215+
def test_where_clause_with_multiple_params
216+
previous_param_builder = ActiveResource::Formats::UrlEncodedFormat.param_builder
217+
ActiveResource::Formats::UrlEncodedFormat.param_builder = ActionDispatch::ParamBuilder
218+
219+
ids = [ [ "id[]", "1" ], [ "id[]", "2" ] ]
220+
ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?" + URI.encode_www_form(ids), {}, @people }
221+
people = Person.where(ids)
222+
assert_equal 2, people.size
223+
assert_kind_of Person, people.first
224+
assert_kind_of Person, people.second
225+
assert_equal [ "Matz", "David" ], people.map(&:name)
226+
ensure
227+
ActiveResource::Formats::UrlEncodedFormat.param_builder = previous_param_builder
228+
end
229+
181230
def test_where_with_clause_in
182231
ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?id%5B%5D=2", {}, @people_david }
183232
people = Person.where(id: [ 2 ])

test/cases/formats/url_encoded_format_test.rb

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "abstract_unit"
4+
require "action_dispatch/http/param_builder"
45

56
class UrlEncodedFormatTest < ActiveSupport::TestCase
67
test "#encode transforms a Hash into an application/x-www-form-urlencoded query string" do
@@ -10,4 +11,88 @@ class UrlEncodedFormatTest < ActiveSupport::TestCase
1011

1112
assert_equal "a=1&b=2&c%5B%5D=3&c%5B%5D=4", encoded
1213
end
14+
15+
test "#encode transforms a nested Hash into an application/x-www-form-urlencoded query string" do
16+
params = { "person" => { "name" => "Matz" } }
17+
18+
encoded = ActiveResource::Formats::UrlEncodedFormat.encode(params)
19+
20+
assert_equal "person%5Bname%5D=Matz", encoded
21+
end
22+
23+
test "#decode transforms an application/x-www-form-urlencoded query string into a Hash" do
24+
query = URI.encode_www_form([ [ "a", "1" ] ])
25+
26+
decoded = ActiveResource::Formats::UrlEncodedFormat.decode(query)
27+
28+
assert_equal({ "a" => "1" }, decoded)
29+
end
30+
31+
test "#decode transforms an Array of query string pairs into a Hash" do
32+
pairs = [ [ "a", "1" ] ]
33+
34+
decoded = ActiveResource::Formats::UrlEncodedFormat.decode(pairs)
35+
36+
assert_equal({ "a" => "1" }, decoded)
37+
end
38+
39+
test "#decode transforms an Array of nested query string pairs into a Hash" do
40+
pairs = [ [ "person[name]", "Matz" ] ]
41+
42+
decoded = ActiveResource::Formats::UrlEncodedFormat.decode(pairs)
43+
44+
assert_equal({ "person[name]" => "Matz" }, decoded)
45+
end
46+
47+
test "#decode transforms an application/x-www-form-urlencoded query string with multiple params into a Hash" do
48+
format = ActiveResource::Formats::UrlEncodedFormat
49+
previous_param_builder = format.param_builder
50+
format.param_builder = ActionDispatch::ParamBuilder
51+
52+
query = URI.encode_www_form([ [ "a[]", "1" ], [ "a[]", "2" ] ])
53+
54+
decoded = format.decode(query)
55+
56+
assert_equal({ "a" => [ "1", "2" ] }, decoded)
57+
ensure
58+
format.param_builder = previous_param_builder
59+
end
60+
61+
test "#decode transforms an Array of query string pairs with multiple params into a Hash" do
62+
format = ActiveResource::Formats::UrlEncodedFormat
63+
previous_param_builder = format.param_builder
64+
format.param_builder = ActionDispatch::ParamBuilder
65+
66+
pairs = [ [ "a[]", "1" ], [ "a[]", "2" ] ]
67+
68+
decoded = format.decode(pairs)
69+
70+
assert_equal({ "a" => [ "1", "2" ] }, decoded)
71+
ensure
72+
format.param_builder = previous_param_builder
73+
end
74+
75+
test "#decode transforms an Array of nested query string pairs into a Hash using ActionDispatch::ParamBuilder" do
76+
format = ActiveResource::Formats::UrlEncodedFormat
77+
previous_param_builder = format.param_builder
78+
format.param_builder = ActionDispatch::ParamBuilder
79+
80+
pairs = [ [ "person[name]", "Matz" ] ]
81+
82+
decoded = format.decode(pairs)
83+
84+
assert_equal({ "person" => { "name" => "Matz" } }, decoded)
85+
ensure
86+
format.param_builder = previous_param_builder
87+
end
88+
89+
test "#decode returns a Hash of query string pairs" do
90+
decoded = ActiveResource::Formats::UrlEncodedFormat.decode("a" => "1")
91+
92+
assert_equal({ "a" => "1" }, decoded)
93+
end
94+
95+
test "#mime_type returns application/x-www-form-urlencoded" do
96+
assert_equal "application/x-www-form-urlencoded", ActiveResource::Formats::UrlEncodedFormat.mime_type
97+
end
1398
end

test/cases/formats_test.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ def test_xml_format_uses_camelcase
1111
assert_equal ActiveResource::Formats::XmlFormat, ActiveResource::Formats[:xml]
1212
end
1313

14+
def test_url_encoded_format
15+
assert_equal ActiveResource::Formats::UrlEncodedFormat, ActiveResource::Formats[:url_encoded]
16+
assert_equal ActiveResource::Formats::UrlEncodedFormat, ActiveResource::Formats[:url_encoded_form]
17+
end
18+
1419
def test_custom_format_uses_camelcase
1520
klass = Class.new
1621
ActiveResource::Formats.const_set(:MsgpackFormat, klass)

0 commit comments

Comments
 (0)