Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rackによるクエリ文字列のパースの実装を調査してみた

はじめに

以下のように同じ名前のフィールドをそれぞれ2つ定義するとします。

<%= text_field_tag "sample[form_items_attributes][0][option_settings][][label]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][description]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][label]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][description]" %>

Rails側に送られてくるパラメータは以下です。

"sample" => {
  "form_items_attributes" => {
    "0" => {
      "option_settings" => [
        {"label"=>"a", "description"=>"b"},
        {"label"=>"a", "description"=>"b"}
      ]
    }
  }
}

option_settingsの配列の中のハッシュが2つに分割されてパラメータとして送られてきます。

これを見るに同じキーがあれば別のハッシュに分割しているように見えます。この記事ではそれが本当なのかRackの実装を通して確認してみました。

Rackのバージョンは2.2.3.1で確認しました。

クエリ文字列のパース

Rails側で参照するパラメータはRackのRack::QueryParser#parse_nested_queryでパースしています。

64:     def parse_nested_query(qs, d = nil)
65:       params = make_params
66:
67:       unless qs.nil? || qs.empty?
68:         (qs || '').split(d ? (COMMON_SEP[d] || /[#{d}] */n) : DEFAULT_SEP).each do |p|
69:           k, v = p.split('=', 2).map! { |s| unescape(s) }
70:
71:           normalize_params(params, k, v, param_depth_limit)
72:         end
73:       end
74:
75:       return params.to_h
76:     rescue ArgumentError => e
77:       raise InvalidParameterError, e.message, e.backtrace
78:     end

qsはクエリ文字列でそこからキーと値を一つずつ取り出しています。

pry(#<Rack::QueryParser>)> k
=> "sample[form_items_attributes][0][option_settings][][label]"

pry(#<Rack::QueryParser>)> v
=> "a"

クエリ文字列のキーと値からパラメータを生成するのはRack::QueryParser#normalize_params で行われています。

83:     def normalize_params(params, name, v, depth)
84:       raise RangeError if depth <= 0
85:
86:       name =~ %r(\A[\[\]]*([^\[\]]+)\]*)
87:       k = $1 || ''
88:       after = $' || ''
89:
90:       if k.empty?
91:         if !v.nil? && name == "[]"
92:           return Array(v)
93:         else
94:           return
95:         end
pry(#<Rack::QueryParser>)> name
=> "sample[form_items_attributes][0][option_settings][][label]"

引数nameとして渡されたキーを以下のように先頭のキー(k)とそれ以外(after)に分割しています。

pry(#<Rack::QueryParser>)> k
=> "sample"
pry(#<Rack::QueryParser>)> after
=> "[form_items_attributes][0][option_settings][][label]"

ここで、分割したk をキーにパラメータを生成しています。パラメータの生成は、afterRack::QueryParser#normalize_paramsにキーとして渡して再帰処理で生成しているようです。

115:       else
116:         params[k] ||= make_params
117:         raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k])
118:         params[k] = normalize_params(params[k], after, v, depth - 1)
119:       end

今回知りたいのは、option_settingsの配列の中のハッシュをどのように生成しているかです。
以下のように、kafterが分割されたときにその処理が行われます。

pry(#<Rack::QueryParser>)> k
=> "option_settings"
pry(#<Rack::QueryParser>)> after
=> "[][label]"

このとき以下の処理が実行されます。

106:       elsif after =~ %r(^\[\]\[([^\[\]]+)\]$) || after =~ %r(^\[\](.+)$)
107:         child_key = $1
108:         params[k] ||= []
109:         raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array)
110:         
111:         if params_hash_type?(params[k].last) && !params_hash_has_key?(params[k].last, child_key)
112:           normalize_params(params[k].last, child_key, v, depth - 1)
113:         else
114:           params[k] << normalize_params(make_params, child_key, v, depth - 1)
115:         end

この処理のなかで生成済みのパラメータのハッシュの中に、これから追加するキーと同じキーがあるのかどうかを!params_hash_has_key?(params[k].last, child_key) で判定しています。
この判定により"option_settings" => [] の配列の中のハッシュに要素を追加するか、新しくハッシュを生成して追加するということを行っています。

例えば以下の場合、child_key("label")が生成済みのパラメータにキーとして含まれているので、params[k] << normalize_params(make_params, child_key, v, depth - 1) が実行され、新しいハッシュとして配列に追加されます。

pry(#<Rack::QueryParser>)> params
=> #<Rack::QueryParser::Params:0x0000004089fee808 @limit=65536, @params={"option_settings"=>[#<Rack::QueryParser::Params:0x00000040883d7598 @limit=65536, @params={"label"=>"a", "description"=>"b"}, @size=16>]}, @size=15>

pry(#<Rack::QueryParser>)> k
=> "option_settings"

pry(#<Rack::QueryParser>)> params[k].last
=> #<Rack::QueryParser::Params:0x00000040883d7598 @limit=65536, @params={"label"=>"a", "description"=>"b"}, @size=16>

pry(#<Rack::QueryParser>)> child_key
=> "label"

ここまでで、配列の中のハッシュはキーが同じなら別のハッシュに分割してくれているということが確認できました。

ということなので3つのlabelのフィールドがあり、以下のようにotherというフィールドを最後の項目の先頭に定義した場合、

<%= text_field_tag "sample[form_items_attributes][0][option_settings][][label]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][description]"%>

<%= text_field_tag "sample[form_items_attributes][0][option_settings][][label]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][description]" %>

<%= text_field_tag "sample[form_items_attributes][0][option_settings][][other]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][label]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][description]" %>

以下のように2番目のハッシュにotherが格納されます。

"sample" => {
  "form_items_attributes" => {
    "0" => {
      "option_settings" => [
        {"label"=>"a", "description"=>"b"},
        {"label"=>"a", "description"=>"b", "other"=>"c"},
        {"label"=>"a", "description"=>"b"}
      ]
    }
  }
}

3番目のハッシュの先頭にotherを追加したい場合は、1番目と2番目にも空の文字列なりを設定したダミーのotherフィールドを追加する必要があります。

<%= hidden_field_tag "sample[form_items_attributes][0][option_settings][][other]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][label]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][description]" %>

<%= hidden_field_tag "sample[form_items_attributes][0][option_settings][][other]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][label]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][description]" %>

<%= text_field_tag "sample[form_items_attributes][0][option_settings][][other]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][label]" %>
<%= text_field_tag "sample[form_items_attributes][0][option_settings][][description]" %>

この場合、以下のようにパースされます。

"sample" => {
  "form_items_attributes" => {
    "0" => {
      "option_settings" => [
        {"other"=>"", "label"=>"a", "description"=>"b"},
        {"other"=>"", "label"=>"a", "description"=>"b"},
        {"other"=>"c", "label"=>"a", "description"=>"b"}
      ]
    }
  }
}

まとめ

Rackのパースの実装を見てみました。配列の中のハッシュはキーが同じなら別のハッシュに分割してくれるということがわかりました。



CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。