From d9fa989df2f69a220623b9aa93377f7fb960cff2 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sat, 20 Dec 2025 10:20:51 -0500 Subject: [PATCH] `where`: Support String arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Gemfile | 2 ++ lib/active_resource/base.rb | 27 +++++++++++++++ .../formats/url_encoded_format.rb | 15 +++++++-- lib/active_resource/railtie.rb | 4 +++ lib/active_resource/where_clause.rb | 1 + test/cases/finder_test.rb | 27 +++++++++++++++ test/cases/formats/url_encoded_format_test.rb | 33 +++++++++++++++++++ 7 files changed, 107 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 473d9b60fd..4c23778ffc 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,7 @@ gem "activesupport", github: "rails/rails", branch: branch gem "activemodel", github: "rails/rails", branch: branch gem "activejob", github: "rails/rails", branch: branch gem "activerecord", github: "rails/rails", branch: branch +gem "rack" gem "sqlite3", branch == "7-0-stable" ? "~> 1.4" : nil gem "rubocop" @@ -18,6 +19,7 @@ gem "rubocop-performance" gem "rubocop-rails" gem "rubocop-rails-omakase" +gem "minitest", "< 6" gem "minitest-bisect" gemspec diff --git a/lib/active_resource/base.rb b/lib/active_resource/base.rb index 10312bb6f5..c0dce6b65e 100644 --- a/lib/active_resource/base.rb +++ b/lib/active_resource/base.rb @@ -1151,7 +1151,34 @@ def all(*args) # This is an alias for all. You can pass in all the same # arguments to this method as you can to all and find(:all) + # + # #where accepts conditions in one of several formats. In the examples below, the resulting + # URL is given as an illustration. + # + # === \String + # + # A string is passed as URL query parameters. + # + # Person.where("name=Matz") + # # https://api.people.com/people.json?name=Matz + # + # === \Hash + # + # #where will also accept a hash condition, in which the keys are fields and the values + # are values to be searched for. + # + # Fields can be symbols or strings. Values can be single values, arrays, or ranges. + # + # Person.where(name: "Matz") + # # https://api.people.com/people.json?name=Matz + # + # Person.where(person: { name: "Matz" }) + # # https://api.people.com/people.json?person[name]=Matz + # + # Article.where(tags: ["Ruby", "Rails"]) + # # https://api.people.com/people.json?tags[]=Ruby&tags[]=Rails def where(clauses = {}) + clauses = query_format.decode(clauses) if clauses.is_a?(String) clauses = sanitize_forbidden_attributes(clauses) raise ArgumentError, "expected a clauses Hash, got #{clauses.inspect}" unless clauses.is_a? Hash all(params: clauses) diff --git a/lib/active_resource/formats/url_encoded_format.rb b/lib/active_resource/formats/url_encoded_format.rb index dc69209fcb..6e3e19f111 100644 --- a/lib/active_resource/formats/url_encoded_format.rb +++ b/lib/active_resource/formats/url_encoded_format.rb @@ -1,16 +1,27 @@ # frozen_string_literal: true -require "active_support/core_ext/array/wrap" - module ActiveResource module Formats module UrlEncodedFormat extend self + attr_accessor :query_parser # :nodoc: + # URL encode the parameters Hash def encode(params, options = nil) params.to_query end + + # URL decode the query string + def decode(query, remove_root = true) + query = query.delete_prefix("?") + + if query_parser == :rack + Rack::Utils.parse_nested_query(query) + else + URI.decode_www_form(query).to_h + end + end end end end diff --git a/lib/active_resource/railtie.rb b/lib/active_resource/railtie.rb index 800f1208e2..e5f6b90197 100644 --- a/lib/active_resource/railtie.rb +++ b/lib/active_resource/railtie.rb @@ -39,5 +39,9 @@ class Railtie < Rails::Railtie teardown { ActiveResource::HttpMock.reset! } end end + + config.after_initialize do + Formats::UrlEncodedFormat.query_parser ||= :rack if defined?(Rack::Utils) + end end end diff --git a/lib/active_resource/where_clause.rb b/lib/active_resource/where_clause.rb index c1072122d2..196cc22216 100644 --- a/lib/active_resource/where_clause.rb +++ b/lib/active_resource/where_clause.rb @@ -13,6 +13,7 @@ def initialize(resource_class, options = {}) end def where(clauses = {}) + clauses = @resource_class.query_format.decode(clauses) if clauses.is_a?(::String) all(params: clauses) end diff --git a/test/cases/finder_test.rb b/test/cases/finder_test.rb index 10326a1c65..4bd1d1c018 100644 --- a/test/cases/finder_test.rb +++ b/test/cases/finder_test.rb @@ -178,6 +178,33 @@ def test_where_clause_with_permitted_params assert_kind_of StreetAddress, addresses.first end + def test_where_clause_string + query = URI.encode_www_form([ [ "id", "1" ] ]) + ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?" + query, {}, @people_david } + people = Person.where(query) + assert_equal 1, people.size + assert_kind_of Person, people.first + assert_equal "David", people.first.name + end + + def test_where_clause_string_chained + ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?a=1&b=2&c=3&id=2", {}, @people_david } + people = Person.where("id=2").where(a: 1).where("b=2").where(c: 3) + assert_equal [ "David" ], people.map(&:name) + end + + def test_where_clause_string_with_multiple_params + previous_query_parser = ActiveResource::Formats::UrlEncodedFormat.query_parser + ActiveResource::Formats::UrlEncodedFormat.query_parser = :rack + + query = URI.encode_www_form([ [ "id[]", "1" ], [ "id[]", "2" ] ]) + ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?" + query, {}, @people } + people = Person.where(query) + assert_equal [ "Matz", "David" ], people.map(&:name) + ensure + ActiveResource::Formats::UrlEncodedFormat.query_parser = previous_query_parser + end + def test_where_with_clause_in ActiveResource::HttpMock.respond_to { |m| m.get "/people.json?id%5B%5D=2", {}, @people_david } people = Person.where(id: [ 2 ]) diff --git a/test/cases/formats/url_encoded_format_test.rb b/test/cases/formats/url_encoded_format_test.rb index a3342eecc7..bdca06c58b 100644 --- a/test/cases/formats/url_encoded_format_test.rb +++ b/test/cases/formats/url_encoded_format_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "abstract_unit" +require "rack/utils" class UrlEncodedFormatTest < ActiveSupport::TestCase test "#encode transforms a Hash into an application/x-www-form-urlencoded query string" do @@ -10,4 +11,36 @@ class UrlEncodedFormatTest < ActiveSupport::TestCase assert_equal "a=1&b=2&c%5B%5D=3&c%5B%5D=4", encoded end + + test "#encode transforms a nested Hash into an application/x-www-form-urlencoded query string" do + params = { "person" => { "name" => "Matz" } } + + encoded = ActiveResource::Formats::UrlEncodedFormat.encode(params) + + assert_equal "person%5Bname%5D=Matz", encoded + end + + test "#decode transforms an application/x-www-form-urlencoded query string into a Hash" do + decoded = ActiveResource::Formats::UrlEncodedFormat.decode("a=1") + + assert_equal({ "a" => "1" }, decoded) + end + + test "#decode ignores a ?-prefix" do + decoded = ActiveResource::Formats::UrlEncodedFormat.decode("?a=1") + + assert_equal({ "a" => "1" }, decoded) + end + + test "#decode transforms an application/x-www-form-urlencoded query string with multiple params into a Hash" do + previous_query_parser = ActiveResource::Formats::UrlEncodedFormat.query_parser + ActiveResource::Formats::UrlEncodedFormat.query_parser = :rack + query = URI.encode_www_form([ [ "a[]", "1" ], [ "a[]", "2" ] ]) + + decoded = ActiveResource::Formats::UrlEncodedFormat.decode(query) + + assert_equal({ "a" => [ "1", "2" ] }, decoded) + ensure + ActiveResource::Formats::UrlEncodedFormat.query_parser = previous_query_parser + end end