Skip to content

Commit d9fa989

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 String 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 or Rack application, rely on [Rack::Utils.parse_nested_query][] to decode key-value pairs. Otherwise, rely on [URI.decode_www_form][] and [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 [Rack::Utils.parse_nested_query]: https://www.rubydoc.info/gems/rack/Rack/Utils#parse_nested_query-class_method [Array#to_h]: https://docs.ruby-lang.org/en/master/Array.html#method-i-to_h
1 parent 4fffccc commit d9fa989

File tree

7 files changed

+107
-2
lines changed

7 files changed

+107
-2
lines changed

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ gem "activesupport", github: "rails/rails", branch: branch
99
gem "activemodel", github: "rails/rails", branch: branch
1010
gem "activejob", github: "rails/rails", branch: branch
1111
gem "activerecord", github: "rails/rails", branch: branch
12+
gem "rack"
1213
gem "sqlite3", branch == "7-0-stable" ? "~> 1.4" : nil
1314

1415
gem "rubocop"
@@ -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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,7 +1151,34 @@ 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+
# === \Hash
1166+
#
1167+
# #where will also accept a hash condition, in which the keys are fields and the values
1168+
# are values to be searched for.
1169+
#
1170+
# Fields can be symbols or strings. Values can be single values, arrays, or ranges.
1171+
#
1172+
# Person.where(name: "Matz")
1173+
# # https://api.people.com/people.json?name=Matz
1174+
#
1175+
# Person.where(person: { name: "Matz" })
1176+
# # https://api.people.com/people.json?person[name]=Matz
1177+
#
1178+
# Article.where(tags: ["Ruby", "Rails"])
1179+
# # https://api.people.com/people.json?tags[]=Ruby&tags[]=Rails
11541180
def where(clauses = {})
1181+
clauses = query_format.decode(clauses) if clauses.is_a?(String)
11551182
clauses = sanitize_forbidden_attributes(clauses)
11561183
raise ArgumentError, "expected a clauses Hash, got #{clauses.inspect}" unless clauses.is_a? Hash
11571184
all(params: clauses)
Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
11
# frozen_string_literal: true
22

3-
require "active_support/core_ext/array/wrap"
4-
53
module ActiveResource
64
module Formats
75
module UrlEncodedFormat
86
extend self
97

8+
attr_accessor :query_parser # :nodoc:
9+
1010
# URL encode the parameters Hash
1111
def encode(params, options = nil)
1212
params.to_query
1313
end
14+
15+
# URL decode the query string
16+
def decode(query, remove_root = true)
17+
query = query.delete_prefix("?")
18+
19+
if query_parser == :rack
20+
Rack::Utils.parse_nested_query(query)
21+
else
22+
URI.decode_www_form(query).to_h
23+
end
24+
end
1425
end
1526
end
1627
end

lib/active_resource/railtie.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,9 @@ class Railtie < Rails::Railtie
3939
teardown { ActiveResource::HttpMock.reset! }
4040
end
4141
end
42+
43+
config.after_initialize do
44+
Formats::UrlEncodedFormat.query_parser ||= :rack if defined?(Rack::Utils)
45+
end
4246
end
4347
end

lib/active_resource/where_clause.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def initialize(resource_class, options = {})
1313
end
1414

1515
def where(clauses = {})
16+
clauses = @resource_class.query_format.decode(clauses) if clauses.is_a?(::String)
1617
all(params: clauses)
1718
end
1819

test/cases/finder_test.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,33 @@ def test_where_clause_with_permitted_params
178178
assert_kind_of StreetAddress, addresses.first
179179
end
180180

181+
def test_where_clause_string
182+
query = URI.encode_www_form([ [ "id", "1" ] ])
183+
ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?" + query, {}, @people_david }
184+
people = Person.where(query)
185+
assert_equal 1, people.size
186+
assert_kind_of Person, people.first
187+
assert_equal "David", people.first.name
188+
end
189+
190+
def test_where_clause_string_chained
191+
ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?a=1&b=2&c=3&id=2", {}, @people_david }
192+
people = Person.where("id=2").where(a: 1).where("b=2").where(c: 3)
193+
assert_equal [ "David" ], people.map(&:name)
194+
end
195+
196+
def test_where_clause_string_with_multiple_params
197+
previous_query_parser = ActiveResource::Formats::UrlEncodedFormat.query_parser
198+
ActiveResource::Formats::UrlEncodedFormat.query_parser = :rack
199+
200+
query = URI.encode_www_form([ [ "id[]", "1" ], [ "id[]", "2" ] ])
201+
ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?" + query, {}, @people }
202+
people = Person.where(query)
203+
assert_equal [ "Matz", "David" ], people.map(&:name)
204+
ensure
205+
ActiveResource::Formats::UrlEncodedFormat.query_parser = previous_query_parser
206+
end
207+
181208
def test_where_with_clause_in
182209
ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?id%5B%5D=2", {}, @people_david }
183210
people = Person.where(id: [ 2 ])

test/cases/formats/url_encoded_format_test.rb

Lines changed: 33 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 "rack/utils"
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,36 @@ 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+
decoded = ActiveResource::Formats::UrlEncodedFormat.decode("a=1")
25+
26+
assert_equal({ "a" => "1" }, decoded)
27+
end
28+
29+
test "#decode ignores a ?-prefix" do
30+
decoded = ActiveResource::Formats::UrlEncodedFormat.decode("?a=1")
31+
32+
assert_equal({ "a" => "1" }, decoded)
33+
end
34+
35+
test "#decode transforms an application/x-www-form-urlencoded query string with multiple params into a Hash" do
36+
previous_query_parser = ActiveResource::Formats::UrlEncodedFormat.query_parser
37+
ActiveResource::Formats::UrlEncodedFormat.query_parser = :rack
38+
query = URI.encode_www_form([ [ "a[]", "1" ], [ "a[]", "2" ] ])
39+
40+
decoded = ActiveResource::Formats::UrlEncodedFormat.decode(query)
41+
42+
assert_equal({ "a" => [ "1", "2" ] }, decoded)
43+
ensure
44+
ActiveResource::Formats::UrlEncodedFormat.query_parser = previous_query_parser
45+
end
1346
end

0 commit comments

Comments
 (0)