From eb773a401c39e29cc28bcfd93b9cd6221cfea01a Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 03:14:53 +0900 Subject: [PATCH 01/15] Implement i18n-aware human_attribute_name for nested attributes and update error messages in specs --- lib/structured_params.rb | 1 + lib/structured_params/i18n.rb | 119 +++++++++++++++++++++++++ lib/structured_params/params.rb | 7 +- spec/errors_spec.rb | 8 +- spec/i18n_spec.rb | 148 ++++++++++++++++++++++++++++++++ spec/params_spec.rb | 98 +++++++++++++++++++++ 6 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 lib/structured_params/i18n.rb create mode 100644 spec/i18n_spec.rb diff --git a/lib/structured_params.rb b/lib/structured_params.rb index 0571214..285ccab 100644 --- a/lib/structured_params.rb +++ b/lib/structured_params.rb @@ -12,6 +12,7 @@ require_relative 'structured_params/errors' require_relative 'structured_params/attribute_methods' require_relative 'structured_params/validations' +require_relative 'structured_params/i18n' # types (load first for module definition) require_relative 'structured_params/type/object' diff --git a/lib/structured_params/i18n.rb b/lib/structured_params/i18n.rb new file mode 100644 index 0000000..a52490e --- /dev/null +++ b/lib/structured_params/i18n.rb @@ -0,0 +1,119 @@ +# rbs_inline: enabled +# frozen_string_literal: true + +module StructuredParams + # Provides i18n-aware human_attribute_name resolution for nested dot-notation + # attributes (e.g. "hobbies.0.name"). + # + # When included in a Params subclass, overrides +human_attribute_name+ so that + # each segment of the path is resolved by the corresponding nested model class, + # ensuring that child-model translations are respected instead of falling back + # to the parent model's i18n context. + # + # == i18n keys + # + # You can customise how array indices and object nesting are rendered by + # defining the following keys in your locale file: + # + # ja: + # structured_params: + # errors: + # nested_attribute: + # array: "%{parent} %{index} 番目の%{child}" + # object: "%{parent}の%{child}" + # + # Without these keys the defaults are: + # array → " " (e.g. "Hobbies 0 Name") + # object → " " (e.g. "Address Postal code") + module I18n + extend ActiveSupport::Concern + + class_methods do # rubocop:disable Metrics/BlockLength + # Override human_attribute_name to resolve nested dot-notation paths. + # + # Flat attributes (no dot) are delegated to the default ActiveModel + # behaviour unchanged. + # + # Example (en default): + # human_attribute_name(:'hobbies.0.name') # => "Hobbies 0 Name" + # + # Example with i18n (ja): + # human_attribute_name(:'hobbies.0.name') # => "趣味 0 番目の名前" + # + #: (Symbol | String, ?Hash[untyped, untyped]) -> String + def human_attribute_name(attribute, options = {}) + parts = attribute.to_s.split('.') + return super if parts.length == 1 + return super unless structured_attributes.key?(parts.first) + + resolve_nested_human_attribute_name(parts) + end + + private + + # Walk +parts+ (e.g. ["hobbies", "0", "name"]) and build a human-readable + # label by delegating each segment to the appropriate nested class. + # + #: (Array[String]) -> String + def resolve_nested_human_attribute_name(parts) + label = nil + klass = self + + attr_segments(parts).each do |index, attr| + human = klass&.human_attribute_name(attr) || attr.humanize + label = build_nested_label(label, index, human) + klass &&= klass.structured_attributes[attr] + end + + label || parts.last.humanize + end + + # Convert a parts array into (index_or_nil, attr) pairs. + # + # attr_segments(["hobbies", "0", "name"]) #=> [[nil, "hobbies"], ["0", "name"]] + # attr_segments(["address", "postal_code"]) #=> [[nil, "address"], [nil, "postal_code"]] + # + #: (Array[String]) -> Array[[String?, String]] + def attr_segments(parts) + index = nil + parts.each_with_object([]) do |part, segments| + if part.match?(/\A\d+\z/) + index = part + else + segments << [index, part] + index = nil + end + end + end + + # Combine +result+ (accumulated label so far), an optional array +index+, + # and the new +attr_human+ into a single label string. + # + # Uses the i18n keys: + # structured_params.errors.nested_attribute.array (parent, index, child) + # structured_params.errors.nested_attribute.object (parent, child) + # + #: (String?, String?, String) -> String + def build_nested_label(result, index, attr_human) + if result.nil? + attr_human + elsif index + ::I18n.t( + 'structured_params.errors.nested_attribute.array', + parent: result, + index: index, + child: attr_human, + default: "#{result} #{index} #{attr_human}" + ) + else + ::I18n.t( + 'structured_params.errors.nested_attribute.object', + parent: result, + child: attr_human, + default: "#{result} #{attr_human}" + ) + end + end + end + end +end diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index 133265b..e0baec5 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -55,6 +55,7 @@ class Params include ActiveModel::Attributes include AttributeMethods include Validations + include I18n # @rbs @errors: ::StructuredParams::Errors? @@ -290,9 +291,11 @@ def serialize_structured_value(value, compact_mode: :none) #: (untyped, String) -> void def import_structured_errors(structured_errors, prefix) structured_errors.each do |error| - # Create dotted attribute path and import normally + # Create dotted attribute path and import with the message already resolved + # in the child model's i18n context, so the parent model's locale does not + # override the child's translations. error_attribute = "#{prefix}.#{error.attribute}" - errors.import(error, attribute: error_attribute.to_sym) + errors.import(error, attribute: error_attribute.to_sym, message: error.message) end end end diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index a23d234..c0fba93 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -33,8 +33,8 @@ it 'returns flat structure with full messages' do expect(errors_to_hash[:name]).to contain_exactly("Name can't be blank") - expect(errors_to_hash[:'address.postal_code']).to contain_exactly("Address postal code can't be blank") - expect(errors_to_hash[:'hobbies.0.name']).to contain_exactly('Hobbies 0 name is required') + expect(errors_to_hash[:'address.postal_code']).to contain_exactly("Address Postal code can't be blank") + expect(errors_to_hash[:'hobbies.0.name']).to contain_exactly('Hobbies 0 Name is required') end end end @@ -78,9 +78,9 @@ it 'returns nested structure with full error messages' do # Check that full messages are used (they include attribute names) expect(errors_to_hash[:name]).to contain_exactly("Name can't be blank") - expect(errors_to_hash[:address]).to include(postal_code: ["Address postal code can't be blank"]) + expect(errors_to_hash[:address]).to include(postal_code: ["Address Postal code can't be blank"]) expect(errors_to_hash[:hobbies]).to include( - '0': hash_including(name: ["Hobbies 0 name can't be blank"]) + '0': hash_including(name: ["Hobbies 0 Name can't be blank"]) ) end end diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb new file mode 100644 index 0000000..a44a10f --- /dev/null +++ b/spec/i18n_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe StructuredParams::I18n do + describe '.human_attribute_name' do + describe 'フラット属性(ドット記法なし)' do + it 'ActiveModel のデフォルト実装に委譲する' do + expect(UserParameter.human_attribute_name(:name)).to eq('Name') + expect(UserParameter.human_attribute_name(:email)).to eq('Email') + expect(UserParameter.human_attribute_name(:age)).to eq('Age') + end + + it 'String でも Symbol でも同じ結果を返す' do + expect(UserParameter.human_attribute_name('name')).to eq('Name') + expect(UserParameter.human_attribute_name(:name)).to eq('Name') + end + + it 'options ハッシュを受け取ってもエラーにならない' do + expect { UserParameter.human_attribute_name(:name, {}) }.not_to raise_error + end + end + + describe 'ネスト object 属性(parent.child)' do + it '親クラスと子クラスの human_attribute_name を連結する' do + expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('Address Postal code') + expect(UserParameter.human_attribute_name(:'address.prefecture')).to eq('Address Prefecture') + expect(UserParameter.human_attribute_name(:'address.city')).to eq('Address City') + end + + it 'String 形式でも同じ結果を返す' do + expect(UserParameter.human_attribute_name('address.postal_code')).to eq('Address Postal code') + end + end + + describe 'ネスト array 属性(parent.index.child)' do + it '親・インデックス・子のラベルをスペース区切りで連結する(デフォルト)' do + expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('Hobbies 0 Name') + expect(UserParameter.human_attribute_name(:'hobbies.0.level')).to eq('Hobbies 0 Level') + expect(UserParameter.human_attribute_name(:'hobbies.0.years_experience')).to eq('Hobbies 0 Years experience') + end + + it '異なるインデックスをそのまま反映する' do + expect(UserParameter.human_attribute_name(:'hobbies.1.name')).to eq('Hobbies 1 Name') + expect(UserParameter.human_attribute_name(:'hobbies.5.level')).to eq('Hobbies 5 Level') + expect(UserParameter.human_attribute_name(:'hobbies.10.name')).to eq('Hobbies 10 Name') + end + end + + describe '子クラスに存在しない属性(フォールバック)' do + it 'humanize した属性名を返す' do + expect(UserParameter.human_attribute_name(:'address.unknown_field')).to eq('Address Unknown field') + expect(UserParameter.human_attribute_name(:'hobbies.0.unknown_field')).to eq('Hobbies 0 Unknown field') + end + end + + describe 'structured_attribute でない属性にドットが含まれる場合' do + it 'super(ActiveModel デフォルト)に委譲する' do + # :name は structured_attribute ではないので super に委譲 + # ActiveModel はドット区切りの末尾セグメントを humanize して返す + expect(UserParameter.human_attribute_name(:'name.something')).to eq('Something') + end + end + end + + describe 'i18n フォーマットキーのカスタマイズ' do + describe 'array フォーマットキー(structured_params.errors.nested_attribute.array)' do + context 'when the array format key is configured' do + include_context 'with ja locale' + + let(:ja_locale_files) { %w[i18n_array_format] } + + it '設定したフォーマットでラベルを生成する' do + expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('趣味 0 番目の名前') + expect(UserParameter.human_attribute_name(:'hobbies.3.level')).to eq('趣味 3 番目のレベル') + expect(UserParameter.human_attribute_name(:'hobbies.10.years_experience')).to eq('趣味 10 番目の経験年数') + end + + it 'object フォーマットキーが未設定の場合はデフォルト(スペース区切り)を使う' do + # structured_params.errors.nested_attribute.object は未設定 + expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('Address Postal code') + end + end + + context 'when the array format key is not configured' do + it 'デフォルトのスペース区切りフォーマットを使う' do + expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('Hobbies 0 Name') + end + end + end + + describe 'object フォーマットキー(structured_params.errors.nested_attribute.object)' do + context 'when the object format key is configured' do + include_context 'with ja locale' + + let(:ja_locale_files) { %w[i18n_object_format] } + + it '設定したフォーマットでラベルを生成する' do + expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('住所の郵便番号') + expect(UserParameter.human_attribute_name(:'address.prefecture')).to eq('住所の都道府県') + end + + it 'array フォーマットキーが未設定の場合はデフォルト(スペース区切り)を使う' do + # structured_params.errors.nested_attribute.array は未設定 + expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('Hobbies 0 Name') + end + end + + context 'when the object format key is not configured' do + it 'デフォルトのスペース区切りフォーマットを使う' do + expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('Address Postal code') + end + end + end + + describe 'value_class の i18n を使うこと' do + context 'when parent and value_class define the same attribute name differently' do + include_context 'with ja locale' + + let(:ja_locale_files) { %w[i18n_value_class] } + + it 'hobbies.0.name には value_class (HobbyParameter) の訳語を使う' do + result = UserParameter.human_attribute_name(:'hobbies.0.name') + expect(result).to eq('趣味 0 番目のホビー名') + end + + it '親クラス (UserParameter) の同名属性訳語は使わない' do + result = UserParameter.human_attribute_name(:'hobbies.0.name') + expect(result).not_to include('ユーザー名') + end + end + end + + describe 'array / object 両キーが設定されている場合' do + include_context 'with ja locale' + + let(:ja_locale_files) { %w[i18n_array_and_object_formats] } + + it 'array 属性には array フォーマットを使う' do + expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('趣味 0 番目の名前') + end + + it 'object 属性には object フォーマットを使う' do + expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('住所の郵便番号') + end + end + end +end diff --git a/spec/params_spec.rb b/spec/params_spec.rb index cb0be5d..7a81110 100644 --- a/spec/params_spec.rb +++ b/spec/params_spec.rb @@ -329,6 +329,104 @@ end end + describe '.human_attribute_name' do + context 'with flat attribute (no dot notation)' do + it 'delegates to default ActiveModel behavior' do + expect(UserParameter.human_attribute_name(:name)).to eq('Name') + expect(UserParameter.human_attribute_name(:email)).to eq('Email') + end + end + + context 'with nested object attribute (address.postal_code)' do + it 'delegates leaf attribute to nested class' do + # AddressParameter.human_attribute_name('postal_code') => "Postal code" + expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('Address Postal code') + end + end + + context 'with array attribute (hobbies.0.name)' do + it 'includes index and delegates leaf to nested class' do + # default en: "Hobbies 0 Name" + expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('Hobbies 0 Name') + end + + it 'reflects different indices correctly' do + expect(UserParameter.human_attribute_name(:'hobbies.2.level')).to eq('Hobbies 2 Level') + end + end + + context 'with mixed nested path (team.members.1.name)' do + it 'resolves object and array nesting in one path' do + expect(OrganizationParameter.human_attribute_name(:'team.members.1.name')).to eq('Team Members 1 Name') + end + end + + context 'with deep object nested path (member.organization.name)' do + it 'resolves multiple object nesting levels' do + expect( + OrganizationParameter.human_attribute_name(:'member.organization.name') + ).to eq('Member Organization Name') + end + end + + context 'with i18n overrides' do + include_context 'with ja locale' + + let(:ja_locale_files) { %w[params_human_attribute_name] } + + it 'formats array nested attribute in Japanese' do + expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('趣味 0 番目の名前') + end + + it 'formats higher index correctly' do + expect(UserParameter.human_attribute_name(:'hobbies.2.level')).to eq('趣味 2 番目のレベル') + end + + it 'formats object nested attribute in Japanese' do + expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('住所の郵便番号') + end + + it 'formats mixed object/array nested attribute in Japanese' do + expect(OrganizationParameter.human_attribute_name(:'team.members.1.name')).to eq('チームのメンバー 1 番目の名前') + end + + it 'formats deep object nested attribute in Japanese' do + expect(OrganizationParameter.human_attribute_name(:'member.organization.name')).to eq('担当者の組織の名称') + end + end + end + + describe 'i18n full_message for array errors' do + subject(:user_param) do + build(:user_parameter, hobbies: [{ name: '', level: 5, years_experience: -1 }]) + end + + before { user_param.valid? } + + context 'with default (en) locale' do + it 'includes index in full_message' do + full_messages = user_param.errors.map(&:full_message) + expect(full_messages).to include(match(/Hobbies 0 Name/)) + end + end + + context 'with i18n overrides (ja)' do + include_context 'with ja locale' + + let(:ja_locale_files) { %w[params_full_message] } + + it 'uses child model i18n for message body' do + full_messages = user_param.errors.map(&:full_message) + expect(full_messages).to include('趣味 0 番目の名前は必須です') + end + + it 'uses child model i18n for years_experience message' do + full_messages = user_param.errors.map(&:full_message) + expect(full_messages).to include('趣味 0 番目の経験年数は0以上にしてください') + end + end + end + describe 'edge cases' do subject(:user_param) { build(:user_parameter, **params) } From b44c579b75cc03c2f969c2e679bf93ed42a1c773 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 03:15:39 +0900 Subject: [PATCH 02/15] Add shared context for Japanese locale in RSpec tests --- spec/support/shared_contexts/ja_locale.rb | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 spec/support/shared_contexts/ja_locale.rb diff --git a/spec/support/shared_contexts/ja_locale.rb b/spec/support/shared_contexts/ja_locale.rb new file mode 100644 index 0000000..acb4dfe --- /dev/null +++ b/spec/support/shared_contexts/ja_locale.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +RSpec.shared_context 'with ja locale' do + let(:ja_locale_files) { [] } + + around do |example| + original_enforce = I18n.enforce_available_locales + I18n.enforce_available_locales = false + I18n.backend.reload! + load_ja_locale_files(ja_locale_files) + I18n.with_locale(:ja) do + example.run + end + I18n.enforce_available_locales = original_enforce + I18n.backend.reload! + end + + def load_ja_locale_files(locale_files) + files = locale_files.map do |name| + File.expand_path("../locales/#{name}.ja.yml", __dir__) + end + I18n.backend.load_translations(*files) unless files.empty? + end +end From dba21163e9f10ce67b2936f6d2661884c73b0908 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 03:50:58 +0900 Subject: [PATCH 03/15] Refactor address and hobby parameter classes; add i18n support for nested attributes --- sig/structured_params/i18n.rbs | 70 +++++++++++ sig/structured_params/params.rbs | 2 + spec/factories/address_parameters.rb | 11 -- spec/factories/hobby_parameters.rb | 10 -- spec/factories/user_parameters.rb | 13 -- spec/form_object_spec.rb | 63 +--------- spec/i18n_spec.rb | 117 ++++++++++++------ spec/params_spec.rb | 60 +++++++-- spec/support/locales/ja.yml | 20 +++ spec/support/shared_contexts/ja_locale.rb | 5 +- .../form_object_classes.rb} | 18 +-- spec/support/test_classes/params_classes.rb | 67 ++++++++++ 12 files changed, 298 insertions(+), 158 deletions(-) create mode 100644 sig/structured_params/i18n.rbs create mode 100644 spec/support/locales/ja.yml rename spec/support/{test_classes.rb => test_classes/form_object_classes.rb} (73%) create mode 100644 spec/support/test_classes/params_classes.rb diff --git a/sig/structured_params/i18n.rbs b/sig/structured_params/i18n.rbs new file mode 100644 index 0000000..c622246 --- /dev/null +++ b/sig/structured_params/i18n.rbs @@ -0,0 +1,70 @@ +# Generated from lib/structured_params/i18n.rb with RBS::Inline + +module StructuredParams + # Provides i18n-aware human_attribute_name resolution for nested dot-notation + # attributes (e.g. "hobbies.0.name"). + # + # When included in a Params subclass, overrides +human_attribute_name+ so that + # each segment of the path is resolved by the corresponding nested model class, + # ensuring that child-model translations are respected instead of falling back + # to the parent model's i18n context. + # + # == i18n keys + # + # You can customise how array indices and object nesting are rendered by + # defining the following keys in your locale file: + # + # ja: + # structured_params: + # errors: + # nested_attribute: + # array: "%{parent} %{index} 番目の%{child}" + # object: "%{parent}の%{child}" + # + # Without these keys the defaults are: + # array → " " (e.g. "Hobbies 0 Name") + # object → " " (e.g. "Address Postal code") + module I18n + extend ActiveSupport::Concern + + # Override human_attribute_name to resolve nested dot-notation paths. + # + # Flat attributes (no dot) are delegated to the default ActiveModel + # behaviour unchanged. + # + # Example (en default): + # human_attribute_name(:'hobbies.0.name') # => "Hobbies 0 Name" + # + # Example with i18n (ja): + # human_attribute_name(:'hobbies.0.name') # => "趣味 0 番目の名前" + # + # : (Symbol | String, ?Hash[untyped, untyped]) -> String + def human_attribute_name: (Symbol | String, ?Hash[untyped, untyped]) -> String + + private + + # Walk +parts+ (e.g. ["hobbies", "0", "name"]) and build a human-readable + # label by delegating each segment to the appropriate nested class. + # + # : (Array[String]) -> String + def resolve_nested_human_attribute_name: (Array[String]) -> String + + # Convert a parts array into (index_or_nil, attr) pairs. + # + # attr_segments(["hobbies", "0", "name"]) #=> [[nil, "hobbies"], ["0", "name"]] + # attr_segments(["address", "postal_code"]) #=> [[nil, "address"], [nil, "postal_code"]] + # + # : (Array[String]) -> Array[[String?, String]] + def attr_segments: (Array[String]) -> Array[[ String?, String ]] + + # Combine +result+ (accumulated label so far), an optional array +index+, + # and the new +attr_human+ into a single label string. + # + # Uses the i18n keys: + # structured_params.errors.nested_attribute.array (parent, index, child) + # structured_params.errors.nested_attribute.object (parent, child) + # + # : (String?, String?, String) -> String + def build_nested_label: (String?, String?, String) -> String + end +end diff --git a/sig/structured_params/params.rbs b/sig/structured_params/params.rbs index d7673ba..7586b2e 100644 --- a/sig/structured_params/params.rbs +++ b/sig/structured_params/params.rbs @@ -58,6 +58,8 @@ module StructuredParams include Validations + include I18n + @errors: ::StructuredParams::Errors? self.@structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]? diff --git a/spec/factories/address_parameters.rb b/spec/factories/address_parameters.rb index c7e3127..2139492 100644 --- a/spec/factories/address_parameters.rb +++ b/spec/factories/address_parameters.rb @@ -1,16 +1,5 @@ # frozen_string_literal: true -class AddressParameter < StructuredParams::Params - attribute :postal_code, :string - attribute :prefecture, :string - attribute :city, :string - attribute :street, :string - - validates :postal_code, presence: true, format: { with: /\A\d{3}-\d{4}\z/ } - validates :prefecture, presence: true - validates :city, presence: true -end - FactoryBot.define do factory :address_parameter, class: 'AddressParameter' do postal_code { '123-4567' } diff --git a/spec/factories/hobby_parameters.rb b/spec/factories/hobby_parameters.rb index f9c4c3f..c185dcb 100644 --- a/spec/factories/hobby_parameters.rb +++ b/spec/factories/hobby_parameters.rb @@ -1,15 +1,5 @@ # frozen_string_literal: true -class HobbyParameter < StructuredParams::Params - attribute :name, :string - attribute :level, :integer - attribute :years_experience, :integer - - validates :name, presence: true - validates :level, inclusion: { in: 1..3 } - validates :years_experience, numericality: { greater_than_or_equal_to: 0 } -end - FactoryBot.define do factory :hobby_parameter, class: 'HobbyParameter' do name { 'programming' } diff --git a/spec/factories/user_parameters.rb b/spec/factories/user_parameters.rb index 5d27d1f..3f365f9 100644 --- a/spec/factories/user_parameters.rb +++ b/spec/factories/user_parameters.rb @@ -1,18 +1,5 @@ # frozen_string_literal: true -class UserParameter < StructuredParams::Params - attribute :name, :string - attribute :email, :string - attribute :age, :integer - attribute :address, :object, value_class: AddressParameter - attribute :hobbies, :array, value_class: HobbyParameter - attribute :tags, :array, value_type: :string - - validates :name, presence: true, length: { maximum: 50 } - validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :age, numericality: { greater_than: 0 } -end - FactoryBot.define do factory :user_parameter, class: 'UserParameter' do name { 'Tanaka Taro' } diff --git a/spec/form_object_spec.rb b/spec/form_object_spec.rb index d77337e..542e6ce 100644 --- a/spec/form_object_spec.rb +++ b/spec/form_object_spec.rb @@ -16,14 +16,6 @@ it 'provides proper route_key' do expect(UserRegistrationForm.model_name.route_key).to eq('user_registrations') end - - it 'provides proper singular form' do - expect(UserRegistrationForm.model_name.singular).to eq('user_registration') - end - - it 'provides proper plural form' do - expect(UserRegistrationForm.model_name.plural).to eq('user_registrations') - end end describe '#persisted?' do @@ -74,11 +66,6 @@ } end - it 'is invalid' do - form = UserRegistrationForm.new(params) - expect(form).not_to be_valid - end - it 'has errors for invalid fields' do form = UserRegistrationForm.new(params) form.valid? @@ -90,19 +77,6 @@ end end - describe 'integration with Rails form helpers' do - it 'provides all necessary methods for form_with' do - form = UserRegistrationForm.new({}) - - # form_with requires these methods - expect(form).to respond_to(:model_name) - expect(form).to respond_to(:persisted?) - expect(form).to respond_to(:to_key) - expect(form).to respond_to(:to_model) - expect(form).to respond_to(:errors) - end - end - describe 'class name with "Parameters" suffix' do it 'removes "Parameters" suffix from model_name' do expect(OrderParameters.model_name.name).to eq('Order') @@ -125,54 +99,21 @@ end describe 'nested class within module' do - it 'handles namespace correctly in name' do + it 'keeps namespace and generates model naming keys' do expect(Admin::UserForm.model_name.name).to eq('Admin::User') - end - - it 'provides correct param_key with namespace' do - # Rails includes namespace in param_key when full name is provided expect(Admin::UserForm.model_name.param_key).to eq('admin_user') - end - - it 'provides correct route_key with namespace' do expect(Admin::UserForm.model_name.route_key).to eq('admin_users') - end - - it 'provides correct i18n_key with namespace' do expect(Admin::UserForm.model_name.i18n_key).to eq(:'admin/user') end end describe 'deeply nested class' do - it 'handles multiple namespaces correctly in name' do + it 'keeps deep namespace and generates model naming keys' do expect(Api::V1::RegistrationForm.model_name.name).to eq('Api::V1::Registration') - end - - it 'provides correct param_key for deeply nested class' do expect(Api::V1::RegistrationForm.model_name.param_key).to eq('api_v1_registration') - end - - it 'provides correct route_key for deeply nested class' do expect(Api::V1::RegistrationForm.model_name.route_key).to eq('api_v1_registrations') - end - - it 'provides correct i18n_key for deeply nested class' do expect(Api::V1::RegistrationForm.model_name.i18n_key).to eq(:'api/v1/registration') end end - - describe 'nested class with Parameters suffix' do - it 'removes suffix and keeps namespace' do - expect(Internal::OrderParameters.model_name.name).to eq('Internal::Order') - end - - it 'provides correct param_key' do - expect(Internal::OrderParameters.model_name.param_key).to eq('internal_order') - end - - it 'provides correct i18n_key' do - expect(Internal::OrderParameters.model_name.i18n_key).to eq(:'internal/order') - end - end end # rubocop:enable RSpec/DescribeClass diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb index a44a10f..26e4909 100644 --- a/spec/i18n_spec.rb +++ b/spec/i18n_spec.rb @@ -4,58 +4,58 @@ RSpec.describe StructuredParams::I18n do describe '.human_attribute_name' do - describe 'フラット属性(ドット記法なし)' do - it 'ActiveModel のデフォルト実装に委譲する' do + describe 'flat attributes (without dot notation)' do + it 'delegates to the default ActiveModel implementation' do expect(UserParameter.human_attribute_name(:name)).to eq('Name') expect(UserParameter.human_attribute_name(:email)).to eq('Email') expect(UserParameter.human_attribute_name(:age)).to eq('Age') end - it 'String でも Symbol でも同じ結果を返す' do + it 'returns the same result for String and Symbol' do expect(UserParameter.human_attribute_name('name')).to eq('Name') expect(UserParameter.human_attribute_name(:name)).to eq('Name') end - it 'options ハッシュを受け取ってもエラーにならない' do + it 'accepts an options hash without raising errors' do expect { UserParameter.human_attribute_name(:name, {}) }.not_to raise_error end end - describe 'ネスト object 属性(parent.child)' do - it '親クラスと子クラスの human_attribute_name を連結する' do + describe 'nested object attributes (parent.child)' do + it 'concatenates parent and child human_attribute_name' do expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('Address Postal code') expect(UserParameter.human_attribute_name(:'address.prefecture')).to eq('Address Prefecture') expect(UserParameter.human_attribute_name(:'address.city')).to eq('Address City') end - it 'String 形式でも同じ結果を返す' do + it 'returns the same result for String input' do expect(UserParameter.human_attribute_name('address.postal_code')).to eq('Address Postal code') end end - describe 'ネスト array 属性(parent.index.child)' do - it '親・インデックス・子のラベルをスペース区切りで連結する(デフォルト)' do + describe 'nested array attributes (parent.index.child)' do + it 'joins parent, index, and child labels with spaces by default' do expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('Hobbies 0 Name') expect(UserParameter.human_attribute_name(:'hobbies.0.level')).to eq('Hobbies 0 Level') expect(UserParameter.human_attribute_name(:'hobbies.0.years_experience')).to eq('Hobbies 0 Years experience') end - it '異なるインデックスをそのまま反映する' do + it 'reflects different indices as-is' do expect(UserParameter.human_attribute_name(:'hobbies.1.name')).to eq('Hobbies 1 Name') expect(UserParameter.human_attribute_name(:'hobbies.5.level')).to eq('Hobbies 5 Level') expect(UserParameter.human_attribute_name(:'hobbies.10.name')).to eq('Hobbies 10 Name') end end - describe '子クラスに存在しない属性(フォールバック)' do - it 'humanize した属性名を返す' do + describe 'fallback for attributes missing in child classes' do + it 'returns a humanized attribute name' do expect(UserParameter.human_attribute_name(:'address.unknown_field')).to eq('Address Unknown field') expect(UserParameter.human_attribute_name(:'hobbies.0.unknown_field')).to eq('Hobbies 0 Unknown field') end end - describe 'structured_attribute でない属性にドットが含まれる場合' do - it 'super(ActiveModel デフォルト)に委譲する' do + describe 'when dotted attributes are not structured attributes' do + it 'delegates to super (ActiveModel default)' do # :name は structured_attribute ではないので super に委譲 # ActiveModel はドット区切りの末尾セグメントを humanize して返す expect(UserParameter.human_attribute_name(:'name.something')).to eq('Something') @@ -63,84 +63,131 @@ end end - describe 'i18n フォーマットキーのカスタマイズ' do - describe 'array フォーマットキー(structured_params.errors.nested_attribute.array)' do + describe 'custom i18n format keys' do + describe 'array format key (structured_params.errors.nested_attribute.array)' do context 'when the array format key is configured' do include_context 'with ja locale' - let(:ja_locale_files) { %w[i18n_array_format] } + let(:ja_overrides) do + { + structured_params: { + errors: { + nested_attribute: { + array: '%s %s 番目の%s' + } + } + } + } + end - it '設定したフォーマットでラベルを生成する' do + it 'builds labels using the configured format' do expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('趣味 0 番目の名前') expect(UserParameter.human_attribute_name(:'hobbies.3.level')).to eq('趣味 3 番目のレベル') expect(UserParameter.human_attribute_name(:'hobbies.10.years_experience')).to eq('趣味 10 番目の経験年数') end - it 'object フォーマットキーが未設定の場合はデフォルト(スペース区切り)を使う' do + it 'uses default space-separated format when object format key is missing' do # structured_params.errors.nested_attribute.object は未設定 - expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('Address Postal code') + expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('住所 郵便番号') end end context 'when the array format key is not configured' do - it 'デフォルトのスペース区切りフォーマットを使う' do + it 'uses the default space-separated format' do expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('Hobbies 0 Name') end end end - describe 'object フォーマットキー(structured_params.errors.nested_attribute.object)' do + describe 'object format key (structured_params.errors.nested_attribute.object)' do context 'when the object format key is configured' do include_context 'with ja locale' - let(:ja_locale_files) { %w[i18n_object_format] } + let(:ja_overrides) do + { + structured_params: { + errors: { + nested_attribute: { + object: '%sの%s' + } + } + } + } + end - it '設定したフォーマットでラベルを生成する' do + it 'builds labels using the configured format' do expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('住所の郵便番号') expect(UserParameter.human_attribute_name(:'address.prefecture')).to eq('住所の都道府県') end - it 'array フォーマットキーが未設定の場合はデフォルト(スペース区切り)を使う' do + it 'uses default space-separated format when array format key is missing' do # structured_params.errors.nested_attribute.array は未設定 - expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('Hobbies 0 Name') + expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('趣味 0 名前') end end context 'when the object format key is not configured' do - it 'デフォルトのスペース区切りフォーマットを使う' do + it 'uses the default space-separated format' do expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('Address Postal code') end end end - describe 'value_class の i18n を使うこと' do + describe 'using value_class i18n translations' do context 'when parent and value_class define the same attribute name differently' do include_context 'with ja locale' - let(:ja_locale_files) { %w[i18n_value_class] } + let(:ja_overrides) do + { + activemodel: { + attributes: { + user: { name: 'ユーザー名' }, + hobby: { name: 'ホビー名' } + } + }, + structured_params: { + errors: { + nested_attribute: { + array: '%s %s 番目の%s' + } + } + } + } + end - it 'hobbies.0.name には value_class (HobbyParameter) の訳語を使う' do + it 'uses value_class (HobbyParameter) translation for hobbies.0.name' do result = UserParameter.human_attribute_name(:'hobbies.0.name') expect(result).to eq('趣味 0 番目のホビー名') end - it '親クラス (UserParameter) の同名属性訳語は使わない' do + it 'does not use the parent class (UserParameter) translation for the same attribute' do result = UserParameter.human_attribute_name(:'hobbies.0.name') expect(result).not_to include('ユーザー名') end end end - describe 'array / object 両キーが設定されている場合' do + describe 'when both array and object keys are configured' do include_context 'with ja locale' - let(:ja_locale_files) { %w[i18n_array_and_object_formats] } + let(:ja_overrides) do + { + structured_params: { + errors: { + nested_attribute: { + array: '%s %s 番目の%s', + object: '%sの%s' + } + } + } + } + end - it 'array 属性には array フォーマットを使う' do + it 'uses array format for array attributes' do expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('趣味 0 番目の名前') end - it 'object 属性には object フォーマットを使う' do + it 'uses object format for object attributes' do expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('住所の郵便番号') end end diff --git a/spec/params_spec.rb b/spec/params_spec.rb index 7a81110..ff4c3e5 100644 --- a/spec/params_spec.rb +++ b/spec/params_spec.rb @@ -355,24 +355,35 @@ end end - context 'with mixed nested path (team.members.1.name)' do - it 'resolves object and array nesting in one path' do - expect(OrganizationParameter.human_attribute_name(:'team.members.1.name')).to eq('Team Members 1 Name') + context 'with nested array path (team.1.name)' do + it 'resolves array nesting in one path' do + expect(OrganizationParameter.human_attribute_name(:'team.1.name')).to eq('Team 1 Name') end end - context 'with deep object nested path (member.organization.name)' do - it 'resolves multiple object nesting levels' do + context 'with deep nested path (team.1.organization.name)' do + it 'resolves array and object nesting levels' do expect( - OrganizationParameter.human_attribute_name(:'member.organization.name') - ).to eq('Member Organization Name') + OrganizationParameter.human_attribute_name(:'team.1.organization.name') + ).to eq('Team 1 Organization Name') end end context 'with i18n overrides' do include_context 'with ja locale' - let(:ja_locale_files) { %w[params_human_attribute_name] } + let(:ja_overrides) do + { + structured_params: { + errors: { + nested_attribute: { + array: '%s %s 番目の%s', + object: '%sの%s' + } + } + } + } + end it 'formats array nested attribute in Japanese' do expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('趣味 0 番目の名前') @@ -386,12 +397,12 @@ expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('住所の郵便番号') end - it 'formats mixed object/array nested attribute in Japanese' do - expect(OrganizationParameter.human_attribute_name(:'team.members.1.name')).to eq('チームのメンバー 1 番目の名前') + it 'formats nested array attribute in Japanese' do + expect(OrganizationParameter.human_attribute_name(:'team.1.name')).to eq('チーム 1 番目の名前') end - it 'formats deep object nested attribute in Japanese' do - expect(OrganizationParameter.human_attribute_name(:'member.organization.name')).to eq('担当者の組織の名称') + it 'formats deep nested attribute in Japanese' do + expect(OrganizationParameter.human_attribute_name(:'team.1.organization.name')).to eq('チーム 1 番目の組織の名称') end end end @@ -413,7 +424,30 @@ context 'with i18n overrides (ja)' do include_context 'with ja locale' - let(:ja_locale_files) { %w[params_full_message] } + let(:ja_overrides) do + { + activemodel: { + errors: { + models: { + hobby: { + attributes: { + name: { blank: 'は必須です' }, + years_experience: { greater_than_or_equal_to: 'は0以上にしてください' } + } + } + } + } + }, + structured_params: { + errors: { + nested_attribute: { + array: '%s %s 番目の%s' + } + } + }, + errors: { format: '%s%s' } + } + end it 'uses child model i18n for message body' do full_messages = user_param.errors.map(&:full_message) diff --git a/spec/support/locales/ja.yml b/spec/support/locales/ja.yml new file mode 100644 index 0000000..dc797e9 --- /dev/null +++ b/spec/support/locales/ja.yml @@ -0,0 +1,20 @@ +ja: + activemodel: + attributes: + user: + hobbies: "趣味" + address: "住所" + hobby: + name: "名前" + level: "レベル" + years_experience: "経験年数" + address: + postal_code: "郵便番号" + prefecture: "都道府県" + organization: + team: "チーム" + team_member: + name: "名前" + organization: "組織" + member_organization: + name: "名称" diff --git a/spec/support/shared_contexts/ja_locale.rb b/spec/support/shared_contexts/ja_locale.rb index acb4dfe..6e6ed66 100644 --- a/spec/support/shared_contexts/ja_locale.rb +++ b/spec/support/shared_contexts/ja_locale.rb @@ -2,6 +2,7 @@ RSpec.shared_context 'with ja locale' do let(:ja_locale_files) { [] } + let(:ja_overrides) { {} } around do |example| original_enforce = I18n.enforce_available_locales @@ -16,9 +17,11 @@ end def load_ja_locale_files(locale_files) + base_file = File.expand_path('../locales/ja.yml', __dir__) files = locale_files.map do |name| File.expand_path("../locales/#{name}.ja.yml", __dir__) end - I18n.backend.load_translations(*files) unless files.empty? + I18n.backend.load_translations(base_file, *files) + I18n.backend.store_translations(:ja, ja_overrides) unless ja_overrides.empty? end end diff --git a/spec/support/test_classes.rb b/spec/support/test_classes/form_object_classes.rb similarity index 73% rename from spec/support/test_classes.rb rename to spec/support/test_classes/form_object_classes.rb index ff0bb3e..21f17f6 100644 --- a/spec/support/test_classes.rb +++ b/spec/support/test_classes/form_object_classes.rb @@ -4,7 +4,9 @@ # rubocop:disable Style/OneClassPerFile -# Additional test helper classes for form object and permit specs +# Classes primarily used by: +# - spec/form_object_spec.rb +# - spec/permit_spec.rb (UserRegistrationForm, Admin::NamespacedForm) # Form object with validations class UserRegistrationForm < StructuredParams::Params @@ -31,7 +33,7 @@ class Profile < StructuredParams::Params attribute :bio, :string end -# Namespaced classes for testing model_name +# Namespaced classes for testing model_name / permit module Admin class UserForm < StructuredParams::Params attribute :name, :string @@ -50,16 +52,4 @@ class RegistrationForm < StructuredParams::Params end end -module Internal - class OrderParameters < StructuredParams::Params - attribute :item_name, :string - end -end - -class StrictAgeParameter < StructuredParams::Params - attribute :age, :integer - - validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' } -end - # rubocop:enable Style/OneClassPerFile diff --git a/spec/support/test_classes/params_classes.rb b/spec/support/test_classes/params_classes.rb new file mode 100644 index 0000000..1ebd275 --- /dev/null +++ b/spec/support/test_classes/params_classes.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'uri' + +# rubocop:disable Style/OneClassPerFile + +# Classes primarily used by: +# - spec/params_spec.rb +# - spec/i18n_spec.rb +# - spec/attribute_methods_spec.rb +# - spec/validations_spec.rb +# - spec/permit_spec.rb (UserParameter) + +class StrictAgeParameter < StructuredParams::Params + attribute :age, :integer + + validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' } +end + +class AddressParameter < StructuredParams::Params + attribute :postal_code, :string + attribute :prefecture, :string + attribute :city, :string + attribute :street, :string + + validates :postal_code, presence: true, format: { with: /\A\d{3}-\d{4}\z/ } + validates :prefecture, presence: true + validates :city, presence: true +end + +class HobbyParameter < StructuredParams::Params + attribute :name, :string + attribute :level, :integer + attribute :years_experience, :integer + + validates :name, presence: true + validates :level, inclusion: { in: 1..3 } + validates :years_experience, numericality: { greater_than_or_equal_to: 0 } +end + +class UserParameter < StructuredParams::Params + attribute :name, :string + attribute :email, :string + attribute :age, :integer + attribute :address, :object, value_class: AddressParameter + attribute :hobbies, :array, value_class: HobbyParameter + attribute :tags, :array, value_type: :string + + validates :name, presence: true, length: { maximum: 50 } + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :age, numericality: { greater_than: 0 } +end + +class MemberOrganizationParameter < StructuredParams::Params + attribute :name, :string +end + +class TeamMemberParameter < StructuredParams::Params + attribute :name, :string + attribute :organization, :object, value_class: MemberOrganizationParameter +end + +class OrganizationParameter < StructuredParams::Params + attribute :team, :array, value_class: TeamMemberParameter +end + +# rubocop:enable Style/OneClassPerFile From 02e4061a68e38d5ffc44e2a0698d8c3d6dc540e4 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 04:13:42 +0900 Subject: [PATCH 04/15] Refactor i18n keys for nested attributes; update references from structured_params to activemodel --- lib/structured_params/i18n.rb | 10 +++++----- spec/i18n_spec.rb | 18 ++++++++---------- spec/params_spec.rb | 12 ++++-------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/lib/structured_params/i18n.rb b/lib/structured_params/i18n.rb index a52490e..9b0b3b8 100644 --- a/lib/structured_params/i18n.rb +++ b/lib/structured_params/i18n.rb @@ -16,7 +16,7 @@ module StructuredParams # defining the following keys in your locale file: # # ja: - # structured_params: + # activemodel: # errors: # nested_attribute: # array: "%{parent} %{index} 番目の%{child}" @@ -90,8 +90,8 @@ def attr_segments(parts) # and the new +attr_human+ into a single label string. # # Uses the i18n keys: - # structured_params.errors.nested_attribute.array (parent, index, child) - # structured_params.errors.nested_attribute.object (parent, child) + # activemodel.errors.nested_attribute.array (parent, index, child) + # activemodel.errors.nested_attribute.object (parent, child) # #: (String?, String?, String) -> String def build_nested_label(result, index, attr_human) @@ -99,7 +99,7 @@ def build_nested_label(result, index, attr_human) attr_human elsif index ::I18n.t( - 'structured_params.errors.nested_attribute.array', + 'activemodel.errors.nested_attribute.array', parent: result, index: index, child: attr_human, @@ -107,7 +107,7 @@ def build_nested_label(result, index, attr_human) ) else ::I18n.t( - 'structured_params.errors.nested_attribute.object', + 'activemodel.errors.nested_attribute.object', parent: result, child: attr_human, default: "#{result} #{attr_human}" diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb index 26e4909..4cdf8b6 100644 --- a/spec/i18n_spec.rb +++ b/spec/i18n_spec.rb @@ -64,13 +64,13 @@ end describe 'custom i18n format keys' do - describe 'array format key (structured_params.errors.nested_attribute.array)' do + describe 'array format key (activemodel.errors.nested_attribute.array)' do context 'when the array format key is configured' do include_context 'with ja locale' let(:ja_overrides) do { - structured_params: { + activemodel: { errors: { nested_attribute: { array: '%s %s 番目の%s' @@ -87,7 +87,7 @@ end it 'uses default space-separated format when object format key is missing' do - # structured_params.errors.nested_attribute.object は未設定 + # activemodel.errors.nested_attribute.object is not configured expect(UserParameter.human_attribute_name(:'address.postal_code')).to eq('住所 郵便番号') end end @@ -99,13 +99,13 @@ end end - describe 'object format key (structured_params.errors.nested_attribute.object)' do + describe 'object format key (activemodel.errors.nested_attribute.object)' do context 'when the object format key is configured' do include_context 'with ja locale' let(:ja_overrides) do { - structured_params: { + activemodel: { errors: { nested_attribute: { object: '%sの%s' @@ -121,7 +121,7 @@ end it 'uses default space-separated format when array format key is missing' do - # structured_params.errors.nested_attribute.array は未設定 + # activemodel.errors.nested_attribute.array is not configured expect(UserParameter.human_attribute_name(:'hobbies.0.name')).to eq('趣味 0 名前') end end @@ -143,9 +143,7 @@ attributes: { user: { name: 'ユーザー名' }, hobby: { name: 'ホビー名' } - } - }, - structured_params: { + }, errors: { nested_attribute: { array: '%s %s 番目の%s' @@ -172,7 +170,7 @@ let(:ja_overrides) do { - structured_params: { + activemodel: { errors: { nested_attribute: { array: '%s %s 番目の%s', diff --git a/spec/params_spec.rb b/spec/params_spec.rb index ff4c3e5..e1c38cd 100644 --- a/spec/params_spec.rb +++ b/spec/params_spec.rb @@ -374,7 +374,7 @@ let(:ja_overrides) do { - structured_params: { + activemodel: { errors: { nested_attribute: { array: '%s %s 番目の%s', @@ -428,6 +428,9 @@ { activemodel: { errors: { + nested_attribute: { + array: '%s %s 番目の%s' + }, models: { hobby: { attributes: { @@ -438,13 +441,6 @@ } } }, - structured_params: { - errors: { - nested_attribute: { - array: '%s %s 番目の%s' - } - } - }, errors: { format: '%s%s' } } end From 36df84b7a9bb8c93be8cd2b5723b1db11b418918 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 04:40:40 +0900 Subject: [PATCH 05/15] Update i18n keys in documentation to reflect changes from structured_params to activemodel --- sig/structured_params/i18n.rbs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sig/structured_params/i18n.rbs b/sig/structured_params/i18n.rbs index c622246..cf07afc 100644 --- a/sig/structured_params/i18n.rbs +++ b/sig/structured_params/i18n.rbs @@ -15,7 +15,7 @@ module StructuredParams # defining the following keys in your locale file: # # ja: - # structured_params: + # activemodel: # errors: # nested_attribute: # array: "%{parent} %{index} 番目の%{child}" @@ -61,8 +61,8 @@ module StructuredParams # and the new +attr_human+ into a single label string. # # Uses the i18n keys: - # structured_params.errors.nested_attribute.array (parent, index, child) - # structured_params.errors.nested_attribute.object (parent, child) + # activemodel.errors.nested_attribute.array (parent, index, child) + # activemodel.errors.nested_attribute.object (parent, child) # # : (String?, String?, String) -> String def build_nested_label: (String?, String?, String) -> String From 251dc30c79960d493fb3c92e41a1744ef918d583 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 05:39:00 +0900 Subject: [PATCH 06/15] Enhance documentation for StructuredParams: update usage examples, add i18n support details, and improve clarity on nested attributes and error handling --- docs/basic-usage.md | 38 ++++-- docs/comparison.md | 242 ---------------------------------- docs/error-handling.md | 33 +++-- docs/form-objects.md | 149 +++++++++++++++++---- docs/installation.md | 31 +++-- docs/serialization.md | 42 +++--- docs/strong-parameters.md | 93 +++++++------ docs/validation.md | 28 ++-- lib/structured_params/i18n.rb | 2 +- 9 files changed, 279 insertions(+), 379 deletions(-) delete mode 100644 docs/comparison.md diff --git a/docs/basic-usage.md b/docs/basic-usage.md index 45c13d3..974ec63 100644 --- a/docs/basic-usage.md +++ b/docs/basic-usage.md @@ -1,5 +1,15 @@ # Basic Usage +Define a parameter class by inheriting from `StructuredParams::Params`. Declare typed attributes with `attribute` and use standard ActiveModel validations. + +## Table of Contents + +- [Basic Parameter Class](#basic-parameter-class) +- [Nested Objects](#nested-objects) +- [Arrays](#arrays) + - [Array of Primitive Types](#array-of-primitive-types) + - [Array of Nested Objects](#array-of-nested-objects) + ## Basic Parameter Class ```ruby @@ -25,6 +35,8 @@ end ## Nested Objects +Use `attribute :name, :object, value_class: SomeParams` to define a nested object. + ```ruby class AddressParams < StructuredParams::Params attribute :street, :string @@ -48,41 +60,47 @@ params = { } user_params = UserParams.new(params) -user_params.address # => AddressParams instance -user_params.address.city # => "New York" +user_params.address # => AddressParams instance +user_params.address.city # => "New York" ``` ## Arrays +Use `attribute :name, :array, ...` to define an array attribute. Specify `value_type` for scalar elements or `value_class` for object elements. + ### Array of Primitive Types +Use `value_type` for arrays of scalar values. + ```ruby class UserParams < StructuredParams::Params - attribute :tags, :array, value_type: :string + attribute :tags, :array, value_type: :string attribute :scores, :array, value_type: :integer end # Usage params = { - tags: ["ruby", "rails", "programming"], + tags: ["ruby", "rails", "programming"], scores: [85, 92, 78] } user_params = UserParams.new(params) -user_params.tags # => ["ruby", "rails", "programming"] +user_params.tags # => ["ruby", "rails", "programming"] user_params.scores # => [85, 92, 78] ``` ### Array of Nested Objects +Use `value_class` for arrays of objects. + ```ruby class HobbyParams < StructuredParams::Params - attribute :name, :string + attribute :name, :string attribute :level, :string end class UserParams < StructuredParams::Params - attribute :name, :string + attribute :name, :string attribute :hobbies, :array, value_class: HobbyParams end @@ -91,11 +109,11 @@ params = { name: "Alice", hobbies: [ { name: "Photography", level: "beginner" }, - { name: "Cooking", level: "intermediate" } + { name: "Cooking", level: "intermediate" } ] } user_params = UserParams.new(params) -user_params.hobbies # => [HobbyParams, HobbyParams] -user_params.hobbies.first.name # => "Photography" +user_params.hobbies # => [HobbyParams, HobbyParams] +user_params.hobbies.first.name # => "Photography" ``` diff --git a/docs/comparison.md b/docs/comparison.md deleted file mode 100644 index 4263972..0000000 --- a/docs/comparison.md +++ /dev/null @@ -1,242 +0,0 @@ -# Comparison with Similar Gems - -This document compares StructuredParams with other parameter handling gems in the Ruby/Rails ecosystem. - -## Overview Comparison - -| Feature | StructuredParams | typed_params | dry-validation | reform | -|---------|------------------|--------------|----------------|---------| -| Type Safety | ✅ ActiveModel::Type | ✅ Built-in types | ✅ Schema validation | ✅ Form objects | -| Nested Objects | ✅ Native support | ❌ Limited | ✅ Schema nesting | ✅ Composition | -| Array Handling | ✅ Typed arrays | ❌ Basic arrays | ✅ Array validation | ✅ Collection forms | -| Strong Parameters | ✅ Auto-generation | ❌ Manual | ❌ Manual | ❌ Manual | -| ActiveModel Integration | ✅ Full compatibility | ❌ Limited | ❌ None | ✅ Full compatibility | -| Form Helpers | ✅ form_with/form_for | ❌ None | ❌ None | ✅ Simple Form | -| Error Handling | ✅ Flat & structured | ✅ Basic | ✅ Detailed | ✅ ActiveModel errors | -| RBS Support | ✅ Built-in | ❌ None | ❌ None | ❌ None | - -## Detailed Comparison - -### vs. typed_params - -**typed_params** provides basic type casting but lacks advanced features: - -```ruby -# typed_params - Basic usage -class UserController < ApplicationController - typed_params do - param :name, type: String, required: true - param :age, type: Integer - end -end - -# StructuredParams - Advanced features -class UserParams < StructuredParams::Params - attribute :name, :string - attribute :age, :integer - attribute :address, :object, value_class: AddressParams - attribute :hobbies, :array, value_class: HobbyParams - - validates :name, presence: true - validates :age, numericality: { greater_than: 0 } -end -``` - -**Advantages of StructuredParams:** -- Native nested object support -- Typed array handling -- Automatic Strong Parameters integration -- Full ActiveModel validation support -- Enhanced error handling with structured formats - -### vs. dry-validation - -**dry-validation** is a powerful validation library but requires more setup: - -```ruby -# dry-validation - Schema definition -UserContract = Dry::Validation.Contract do - params do - required(:name).filled(:string) - required(:age).filled(:integer) - optional(:address).hash do - required(:street).filled(:string) - required(:city).filled(:string) - end - end - - rule(:age) { failure('must be positive') if value <= 0 } -end - -# StructuredParams - Simpler approach -class UserParams < StructuredParams::Params - attribute :name, :string - attribute :age, :integer - attribute :address, :object, value_class: AddressParams - - validates :name, presence: true - validates :age, numericality: { greater_than: 0 } -end -``` - -**Advantages of StructuredParams:** -- More Rails-friendly syntax -- Built-in Strong Parameters support -- ActiveModel compatibility for serialization -- Less boilerplate code -- Native object composition - -### vs. reform - -**reform** provides form objects but with different focus: - -```ruby -# reform - Form object approach -class UserForm < Reform::Form - property :name - property :age - - collection :addresses do - property :street - property :city - end - - validates :name, presence: true -end - -# StructuredParams - Parameter object approach -class UserParams < StructuredParams::Params - attribute :name, :string - attribute :age, :integer - attribute :addresses, :array, value_class: AddressParams - - validates :name, presence: true -end - -# StructuredParams can also be used as form objects -class UserRegistrationForm < StructuredParams::Params - attribute :name, :string - attribute :email, :string - attribute :password, :string - - validates :name, presence: true - validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } -end - -# In views -<%= form_with model: @form, url: users_path do |f| %> - <%= f.text_field :name %> - <%= f.email_field :email %> - <%= f.password_field :password %> -<% end %> -``` - -**Advantages of StructuredParams:** -- Focus on parameter handling rather than form rendering -- Automatic type casting with ActiveModel::Type -- Built-in Strong Parameters integration -- Cleaner syntax for API-first applications -- Better TypeScript/RBS integration -- **Can be used as form objects** with Rails form helpers (form_with/form_for) -- **Dual purpose**: Strong Parameters validation AND form object pattern - -## When to Choose StructuredParams - -Choose **StructuredParams** when you need: - -1. **Type-safe parameter handling** in Rails APIs -2. **Complex nested structures** with automatic casting -3. **Strong Parameters integration** without manual permit lists -4. **ActiveModel compatibility** for validations and serialization -5. **Form object pattern** with Rails form helpers integration -6. **Enhanced error handling** with structured formats -7. **RBS type definitions** for better development experience -8. **Dual-purpose classes** that work as both parameter validators and form objects - -## Migration Examples - -### From typed_params - -```ruby -# Before: typed_params -class UsersController < ApplicationController - typed_params do - param :user, type: Hash do - param :name, type: String, required: true - param :age, type: Integer - end - end - - def create - # Manual parameter extraction and validation - end -end - -# After: StructuredParams -class UserParams < StructuredParams::Params - attribute :name, :string - attribute :age, :integer - - validates :name, presence: true -end - -class UsersController < ApplicationController - def create - user_params = UserParams.new(params[:user]) - if user_params.valid? - User.create!(user_params.attributes) - else - render json: { errors: user_params.errors.to_hash } - end - end -end -``` - -### From dry-validation - -```ruby -# Before: dry-validation -UserContract = Dry::Validation.Contract do - params do - required(:name).filled(:string) - required(:age).filled(:integer) - end -end - -def create - result = UserContract.call(params[:user]) - if result.success? - User.create!(result.to_h) - else - render json: { errors: result.errors.to_h } - end -end - -# After: StructuredParams -class UserParams < StructuredParams::Params - attribute :name, :string - attribute :age, :integer - - validates :name, presence: true -end - -def create - user_params = UserParams.new(params[:user]) - if user_params.valid? - User.create!(user_params.attributes) - else - render json: { errors: user_params.errors.to_hash } - end -end -``` - -## Performance Considerations - -StructuredParams leverages ActiveModel::Type system, which provides: - -- **Efficient type casting** with built-in Rails optimizations -- **Memory-efficient validation** using ActiveModel's proven patterns -- **Lazy loading** of nested objects only when accessed -- **Cached permit lists** for Strong Parameters integration - -For high-throughput APIs, StructuredParams typically performs comparably to or better than manual parameter handling while providing significantly more features and type safety. diff --git a/docs/error-handling.md b/docs/error-handling.md index e83e8eb..76fe756 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -1,6 +1,15 @@ # Error Handling -StructuredParams provides enhanced error handling for nested structures with a custom `Errors` class that supports both flat and structured error formats: +StructuredParams provides enhanced error handling for nested structures via a custom `Errors` class that supports both flat and structured error formats. + +## Table of Contents + +- [Basic Error Access](#basic-error-access) +- [Structured Error Format](#structured-error-format) +- [Custom Error Key Formatting](#custom-error-key-formatting) +- [API Response Examples](#api-response-examples) + - [Flat Error Format](#flat-error-format) + - [JSON:API Pointer Format](#jsonapi-pointer-format) ## Basic Error Access @@ -19,7 +28,7 @@ user_params.errors.full_messages ## Structured Error Format -For better integration with frontend applications, you can get errors in a nested structure: +For better integration with frontend applications, errors can be returned as a nested structure. ```ruby # Get errors in structured format (symbol keys) @@ -40,7 +49,7 @@ user_params.errors.to_hash(true, structured: true) ## Custom Error Key Formatting -You can transform error keys using standard Ruby methods for different output formats: +Transform error keys with standard Ruby methods to produce any output format you need. ```ruby # JSON Pointer format @@ -58,7 +67,9 @@ user_params.errors.to_hash.transform_keys { |key| "field_#{key}" } ## API Response Examples -### JSON API Format +### Flat Error Format + +A simple pattern that renders the structured error hash directly in the response. ```ruby class UsersController < ApplicationController @@ -69,17 +80,18 @@ class UsersController < ApplicationController User.create!(user_params.attributes) render json: { success: true } else - # Choose the error format that best fits your frontend needs - render json: { + render json: { errors: user_params.errors.to_hash(false, structured: true), - success: false + success: false }, status: :unprocessable_entity end end end ``` -### JSON:API Compliant Format +### JSON:API Pointer Format + +Produces errors that conform to the [JSON:API](https://jsonapi.org/) specification, with field paths expressed as JSON Pointers in `source.pointer`. ```ruby def create @@ -88,15 +100,14 @@ def create if user_params.valid? # ... success handling else - # Transform to JSON:API errors format - json_api_errors = user_params.errors.to_hash.map do |field, messages| + json_api_errors = user_params.errors.to_hash.flat_map do |field, messages| messages.map do |message| { source: { pointer: "/data/attributes/#{field.to_s.gsub('.', '/')}" }, detail: message } end - end.flatten + end render json: { errors: json_api_errors }, status: :unprocessable_entity end diff --git a/docs/form-objects.md b/docs/form-objects.md index edb7c09..08b66b8 100644 --- a/docs/form-objects.md +++ b/docs/form-objects.md @@ -1,10 +1,32 @@ # Using as Form Objects -`StructuredParams::Params` can be used as a Rails form object pattern. Integration with form helpers (`form_with`, `form_for`) makes it easy to use in views. - -## Basic Usage - -### Defining a Form Object +`StructuredParams::Params` can be used as a Rails form object. It integrates with `form_with` / `form_for` and works seamlessly in views. + +## Table of Contents + +- [Defining a Form Object](#defining-a-form-object) +- [Using in Controllers](#using-in-controllers) +- [Using in Views](#using-in-views) +- [Benefits of Form Objects](#benefits-of-form-objects) + - [Separation from Models](#separation-from-models) + - [Combining Multiple Models](#combining-multiple-models) + - [Nested Forms](#nested-forms) +- [Class Name Conventions](#class-name-conventions) + - [Nested Modules](#nested-modules) +- [i18n Support](#i18n-support) + - [Setting Up Translation Files](#setting-up-translation-files) + - [Customizing Nested Attribute Labels](#customizing-nested-attribute-labels) +- [API Integration](#api-integration) +- [Strong Parameters Integration](#strong-parameters-integration) +- [Testing](#testing) +- [Best Practices](#best-practices) + - [Base Form Class with Auto-permit](#base-form-class-with-auto-permit) + - [Implementing a save Method](#implementing-a-save-method) + - [Using Transactions](#using-transactions) + - [Conditional Validations](#conditional-validations) +- [Related Documentation](#related-documentation) + +## Defining a Form Object ```ruby class UserRegistrationForm < StructuredParams::Params @@ -32,7 +54,7 @@ class UserRegistrationForm < StructuredParams::Params end ``` -### Using in Controllers +## Using in Controllers ```ruby class UsersController < ApplicationController @@ -56,7 +78,7 @@ end # params.require(:user_registration).permit(UserRegistrationForm.permit_attribute_names) ``` -### Using in Views +## Using in Views ```erb <%= form_with model: @form, url: users_path do |f| %> @@ -104,9 +126,9 @@ end ## Benefits of Form Objects -### 1. Separation from Models +### Separation from Models -Using form objects allows you to separate validation logic from models. +Form objects let you separate validation logic from persistence models. ```ruby # Model focuses on persistence @@ -126,9 +148,9 @@ class UserRegistrationForm < StructuredParams::Params end ``` -### 2. Combining Multiple Models +### Combining Multiple Models -You can easily create forms that handle multiple models together. +Easily create forms that handle multiple models together. ```ruby class UserProfileForm < StructuredParams::Params @@ -160,9 +182,9 @@ class ProfileAttributes < StructuredParams::Params end ``` -### 3. Nested Forms +### Nested Forms -Forms with nested attributes are also easy to handle. +Define forms with nested attributes concisely. ```ruby class OrderForm < StructuredParams::Params @@ -201,7 +223,7 @@ UserParameters.model_name.name # => "User" ### Nested Modules -When defined within a module, the namespace is preserved: +When defined inside a module, the namespace is preserved. ```ruby module Admin @@ -217,7 +239,7 @@ Admin::UserForm.model_name.route_key # => "admin_users" ## i18n Support -Form objects are integrated with Rails' i18n system. +Form objects integrate with Rails' i18n system. ### Setting Up Translation Files @@ -242,7 +264,7 @@ ja: confirmation: "パスワードが一致しません" ``` -### Using in Views +Use `model_name.human` in views to display the translated model name: ```erb <%= form_with model: @form, url: users_path do |f| %> @@ -256,6 +278,36 @@ ja: <% end %> ``` +### Customizing Nested Attribute Labels + +Labels for dot-notation nested attributes (e.g. `hobbies.0.name`, `address.postal_code`) can be customized via: + +- `activemodel.errors.nested_attribute.array` — label for array elements (uses `%{parent}`, `%{index}`, `%{child}`) +- `activemodel.errors.nested_attribute.object` — label for nested objects (uses `%{parent}`, `%{child}`) + +```yaml +# config/locales/ja.yml +ja: + activemodel: + attributes: + user: + hobbies: "趣味" + address: "住所" + hobby: + name: "名前" + address: + postal_code: "郵便番号" + errors: + nested_attribute: + array: "%{parent} %{index} 番目の%{child}" + object: "%{parent}の%{child}" +``` + +Examples: + +- `UserParameter.human_attribute_name(:'hobbies.0.name') # => "趣味 0 番目の名前"` +- `UserParameter.human_attribute_name(:'address.postal_code') # => "住所の郵便番号"` + ## API Integration Form objects can also be used for API request validation. @@ -277,16 +329,15 @@ end ## Strong Parameters Integration -Form objects are automatically integrated with Strong Parameters. +Form objects integrate automatically with Strong Parameters. ```ruby class UsersController < ApplicationController def create - # permit method automatically executes require and permit + # permit automatically calls require and permit internally @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) if @form.valid? - # Save to database user = User.create!(@form.attributes) redirect_to user else @@ -295,7 +346,7 @@ class UsersController < ApplicationController end end -# If you want manual control +# For manual control class UsersController < ApplicationController def create permitted_params = params.require(:user_registration).permit( @@ -316,7 +367,7 @@ end ## Testing -Form object tests can be easily written with standard RSpec. +Form objects are straightforward to test with standard RSpec. ```ruby RSpec.describe UserRegistrationForm do @@ -360,9 +411,55 @@ end ## Best Practices -### 1. Implementing the save Method +### Base Form Class with Auto-permit + +When using form objects with Rails views, wrapping `permit` inside `initialize` via a shared base class eliminates the repetitive `FormClass.permit(params)` pattern in every controller action. + +```ruby +# app/forms/application_form.rb +class ApplicationForm < StructuredParams::Params + def initialize(params) + permitted = params.is_a?(ActionController::Parameters) ? self.class.permit(params) : params + super(permitted) + end +end +``` + +All form objects inherit from `ApplicationForm`: + +```ruby +class UserRegistrationForm < ApplicationForm + attribute :name, :string + attribute :email, :string + attribute :password, :string +end +``` + +Controllers become simpler — `permit` is called transparently: + +```ruby +# Before +@form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) + +# After +@form = UserRegistrationForm.new(params) +``` + +The `ActionController::Parameters` guard ensures that plain hashes (e.g. in tests or `def new`) are passed through unchanged: + +```ruby +# Works fine in the new action +@form = UserRegistrationForm.new({}) + +# Works fine in tests +form = UserRegistrationForm.new(name: "Alice", email: "alice@example.com") +``` + +> **Note:** This pattern is most useful when you consistently use form objects with Rails views. For API-only parameter classes, the plain `UserParams.new(params)` approach is sufficient and requires no base class. + +### Implementing a save Method -Implementing a `save` method in the form object helps keep controllers simple. +Adding a `save` method to the form object keeps controllers simple. ```ruby class UserRegistrationForm < StructuredParams::Params @@ -391,7 +488,7 @@ def create end ``` -### 2. Using Transactions +### Using Transactions Use transactions when creating multiple models. @@ -410,9 +507,9 @@ rescue ActiveRecord::RecordInvalid end ``` -### 3. Conditional Validations +### Conditional Validations -You can implement validations based on state. +Apply validations conditionally based on state. ```ruby class UserUpdateForm < StructuredParams::Params diff --git a/docs/installation.md b/docs/installation.md index f15dbf9..ca4c23c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,50 +1,55 @@ # Installation and Setup +Steps to add StructuredParams to a Rails application. + +## Table of Contents + +- [Installation](#installation) +- [Setup](#setup) +- [Custom Type Registration](#custom-type-registration) + ## Installation -Add this line to your application's Gemfile: +Add the gem to your Gemfile: ```ruby gem 'structured_params' ``` -And then execute: - ```bash -$ bundle install +bundle install ``` -Or install it yourself as: +Or install it directly: ```bash -$ gem install structured_params +gem install structured_params ``` ## Setup -Register the custom types in your Rails application: +Register the custom types in a Rails initializer: ```ruby # config/initializers/structured_params.rb StructuredParams.register_types ``` -This registers `:object` and `:array` types with ActiveModel::Type. +This registers the `:object` and `:array` types with ActiveModel::Type. -### Custom Type Registration +## Custom Type Registration -If you want to avoid potential naming conflicts, you can register types with custom names: +To avoid naming conflicts with existing code, register the types under custom names: ```ruby -# Register with custom names StructuredParams.register_types_as( object_name: :structured_object, - array_name: :structured_array + array_name: :structured_array ) # Then use in your parameter classes class UserParams < StructuredParams::Params attribute :address, :structured_object, value_class: AddressParams - attribute :hobbies, :structured_array, value_class: HobbyParams + attribute :hobbies, :structured_array, value_class: HobbyParams end ``` diff --git a/docs/serialization.md b/docs/serialization.md index 5f413f1..3a68deb 100644 --- a/docs/serialization.md +++ b/docs/serialization.md @@ -1,18 +1,17 @@ # Serialization -StructuredParams provides multiple ways to serialize your parameter objects: +StructuredParams provides multiple ways to serialize parameter objects to Hash or JSON. Nested objects are serialized recursively. -## Basic Serialization +## Table of Contents -```ruby -user_params = UserParams.new(params) -user_params.attributes # => Hash with all attributes -user_params.to_json # => JSON string -``` +- [attributes Method](#attributes-method) +- [Symbol vs String Keys](#symbol-vs-string-keys) +- [JSON Serialization](#json-serialization) +- [Integration with ActiveRecord](#integration-with-activerecord) -## Attributes Method +## attributes Method -The `attributes` method returns a hash representation of all attributes, with nested objects properly serialized: +`attributes` returns all attributes as a nested Hash. ```ruby user_params = UserParams.new({ @@ -20,28 +19,28 @@ user_params = UserParams.new({ address: { street: "123 Main St", city: "New York" }, hobbies: [ { name: "Photography", level: "beginner" }, - { name: "Cooking", level: "intermediate" } + { name: "Cooking", level: "intermediate" } ] }) user_params.attributes # => { -# "name" => "John Doe", +# "name" => "John Doe", # "address" => { "street" => "123 Main St", "city" => "New York" }, # "hobbies" => [ # { "name" => "Photography", "level" => "beginner" }, -# { "name" => "Cooking", "level" => "intermediate" } +# { "name" => "Cooking", "level" => "intermediate" } # ] # } ``` ## Symbol vs String Keys -By default, `attributes` returns string keys. You can get symbol keys instead: +`attributes` returns string keys by default. Pass `symbolize: true` to get symbol keys instead. ```ruby -user_params.attributes(symbolize: false) # Default: string keys -user_params.attributes(symbolize: true) # Symbol keys +user_params.attributes # => string keys (default) +user_params.attributes(symbolize: true) # => symbol keys ``` ## JSON Serialization @@ -49,16 +48,13 @@ user_params.attributes(symbolize: true) # Symbol keys StructuredParams integrates with Rails' JSON serialization: ```ruby -user_params.to_json -# => JSON string representation - -user_params.as_json -# => Hash ready for JSON serialization +user_params.to_json # => JSON string +user_params.as_json # => Hash ready for JSON serialization ``` ## Integration with ActiveRecord -You can easily pass StructuredParams attributes to ActiveRecord models: +Pass `attributes` directly to ActiveRecord models: ```ruby class UsersController < ApplicationController @@ -66,10 +62,10 @@ class UsersController < ApplicationController user_params = UserParams.new(params[:user]) if user_params.valid? - # Direct attribute passing + # Pass all attributes at once user = User.create!(user_params.attributes) - # Or with specific attributes + # Or exclude specific attributes user = User.new user.assign_attributes(user_params.attributes.except('internal_field')) user.save! diff --git a/docs/strong-parameters.md b/docs/strong-parameters.md index d7f19e9..dc5eb87 100644 --- a/docs/strong-parameters.md +++ b/docs/strong-parameters.md @@ -1,15 +1,29 @@ # Strong Parameters Integration -StructuredParams provides flexible ways to handle Strong Parameters for different use cases: +StructuredParams provides three ways to integrate with Strong Parameters depending on your use case. -## 1. API Requests (simple) +## Table of Contents -For API endpoints, simply pass `params` directly: +- [API Requests](#api-requests) +- [Form Objects](#form-objects) +- [Manual Control](#manual-control) +- [Automatic Permit List Generation](#automatic-permit-list-generation) +- [Controller Patterns](#controller-patterns) + - [API Controller](#api-controller) + - [Form Object Controller](#form-object-controller) +- [How `permit` Determines the Parameter Key](#how-permit-determines-the-parameter-key) +- [Choosing the Right Approach](#choosing-the-right-approach) + - [`UserParams.new(params)` — Recommended for APIs](#userparamsnewparams--recommended-for-apis) + - [`permit` method — Required for Form Objects](#permit-method--required-for-form-objects) + - [`permit_attribute_names` — Manual Control](#permit_attribute_names--manual-control) + +## API Requests + +For API endpoints, pass `params` directly. Only defined attributes are extracted automatically. ```ruby class Api::V1::UsersController < ApplicationController def create - # Simply pass params - unpermitted params are automatically filtered user_params = UserParams.new(params) if user_params.valid? @@ -25,23 +39,21 @@ end # { "name": "John", "email": "john@example.com", "age": 30 } ``` -**Note:** `StructuredParams::Params` automatically extracts only defined attributes from unpermitted `ActionController::Parameters`, providing the same protection as Strong Parameters without explicit `permit` calls. +> **Note:** `StructuredParams::Params` automatically extracts only defined attributes from unpermitted `ActionController::Parameters`, providing the same protection as Strong Parameters without explicit `permit` calls. -**Alternative (explicit):** If you prefer to be explicit, you can use `permit` with `require: false`: +If you prefer to be explicit, use `permit` with `require: false`: ```ruby -# Explicit permit (optional) user_params = UserParams.new(UserParams.permit(params, require: false)) ``` -## 2. Form Objects (with require) +## Form Objects -For web forms, use `permit` with default `require: true`: +For web forms, use `permit` (default `require: true`). It automatically resolves nested parameter keys such as `params[:user_registration]`. ```ruby class UsersController < ApplicationController def create - # permit with require - expects params[:user_registration] @form = UserRegistrationForm.new(UserRegistrationForm.permit(params)) if @form.valid? @@ -57,9 +69,9 @@ end # params = { user_registration: { name: "John", email: "john@example.com" } } ``` -## 3. Manual Control (Traditional - Backward Compatible) +## Manual Control -If you need more control, you can use `permit_attribute_names` directly: +For fine-grained control, use `permit_attribute_names` directly. ```ruby class UsersController < ApplicationController @@ -79,30 +91,28 @@ end # [:name, :age, :email, { address: [:street, :city, :postal_code] }, { hobbies: [:name, :level] }] ``` -**Note:** Both approaches are fully supported and maintain backward compatibility. Existing code using `permit_attribute_names` will continue to work without any changes. +> **Note:** Existing code using `permit_attribute_names` continues to work without any changes — full backward compatibility is maintained. ## Automatic Permit List Generation -The `permit_attribute_names` method automatically generates the correct structure for nested objects and arrays: +`permit_attribute_names` automatically generates the correct structure for nested objects and arrays. ```ruby class UserParams < StructuredParams::Params - attribute :name, :string - attribute :age, :integer + attribute :name, :string + attribute :age, :integer attribute :address, :object, value_class: AddressParams - attribute :hobbies, :array, value_class: HobbyParams - attribute :tags, :array, value_type: :string + attribute :hobbies, :array, value_class: HobbyParams + attribute :tags, :array, value_type: :string end UserParams.permit_attribute_names # => [:name, :age, { address: [:street, :city, :postal_code] }, { hobbies: [:name, :level] }, { tags: [] }] ``` -## Controller Pattern - -Here's a typical controller pattern using StructuredParams: +## Controller Patterns -### API Controller (Simple) +### API Controller ```ruby class Api::V1::UsersController < ApplicationController @@ -130,7 +140,7 @@ class Api::V1::UsersController < ApplicationController end ``` -### Form Object Controller (With require) +### Form Object Controller ```ruby class UsersController < ApplicationController @@ -147,9 +157,9 @@ class UsersController < ApplicationController end ``` -## How `permit` determines the parameter key +## How `permit` Determines the Parameter Key -The `permit` method uses `model_name.param_key` to determine which key to require: +`permit` uses `model_name.param_key` to determine which key to `require`: ```ruby UserParams.permit(params) @@ -162,24 +172,25 @@ Admin::UserForm.permit(params) # Internally calls: params.require(:admin_user).permit(...) ``` -See [Form Objects](form-objects.md) for more details about `model_name` customization. +See [Form Objects](form-objects.md) for details on `model_name` customization. -## When to use `permit` method? +## Choosing the Right Approach -### Use `UserParams.new(params)` (Recommended for API) -- ✅ **Simple and clean** - No boilerplate code -- ✅ **Automatic filtering** - Unpermitted attributes are automatically filtered -- ✅ **Same protection** - Provides the same security as Strong Parameters +### `UserParams.new(params)` — Recommended for APIs + +- ✅ **Simple** — no boilerplate +- ✅ **Automatic filtering** — undefined attributes are excluded +- ✅ **Same protection** — equivalent security to Strong Parameters ```ruby -# API endpoint - Recommended user_params = UserParams.new(params) ``` -### Use `permit` method (Required for Form Objects) -- ✅ **Required for form helpers** - When using `form_with`/`form_for` in views -- ✅ **Nested param extraction** - Automatically extracts from nested structure like `params[:user_registration]` -- ✅ **Explicit about intent** - Makes it clear you're using Strong Parameters +### `permit` method — Required for Form Objects + +- ✅ **Required for form helpers** — when using `form_with`/`form_for` in views +- ✅ **Nested key resolution** — automatically extracts from `params[:user_registration]` etc. +- ✅ **Explicit intent** — makes Strong Parameters usage clear ```ruby # Form object - Required @@ -189,13 +200,13 @@ user_params = UserParams.new(params) user_params = UserParams.new(UserParams.permit(params, require: false)) ``` -### Use `permit_attribute_names` (Manual control) -- ✅ **Custom permit logic** - When you need to add extra fields -- ✅ **Backward compatibility** - For existing codebases -- ✅ **Fine-grained control** - When integrating with complex Strong Parameters code +### `permit_attribute_names` — Manual Control + +- ✅ **Custom permit logic** — add extra fields beyond the defined attributes +- ✅ **Backward compatibility** — drop-in for existing codebases +- ✅ **Fine-grained control** — integrate with complex Strong Parameters code ```ruby -# Custom permit logic permitted = params.require(:user).permit(*UserParams.permit_attribute_names, :custom_field) user_params = UserParams.new(permitted) ``` diff --git a/docs/validation.md b/docs/validation.md index 8e1cdb8..48acfa9 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -1,6 +1,15 @@ # Validation -Since StructuredParams inherits from ActiveModel, you can use all ActiveModel validations: +Since StructuredParams inherits from ActiveModel, you can use all standard ActiveModel validations. Validation cascades automatically to nested objects and arrays. + +## Table of Contents + +- [Basic Validations](#basic-validations) +- [Validate Raw Input (`validates_raw`)](#validate-raw-input-validates_raw) + - [Combining `validates_raw` and `validates`](#combining-validates_raw-and-validates) +- [Nested Validation](#nested-validation) + +## Basic Validations ```ruby class UserParams < StructuredParams::Params @@ -26,13 +35,12 @@ end ## Validate Raw Input (`validates_raw`) -Use `validates_raw` to validate the original input value before type casting. +Use `validates_raw` to validate the original input value before type casting. This is useful for rejecting partially numeric strings like `"12x"` that would otherwise be silently cast. ```ruby class UserParams < StructuredParams::Params attribute :age, :integer - # Validate raw input before type casting to avoid accepting partially numeric strings (e.g. "12x"). validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' } end @@ -41,12 +49,11 @@ params.valid? # => false params.errors.to_hash # => { age: ["must be numeric string"] } ``` -`validates_raw` uses `*_before_type_cast` internally, then remaps errors back to the original attribute. -So `errors[:age_before_type_cast]` remains empty in normal usage. +`validates_raw` uses `*_before_type_cast` internally, then remaps errors back to the original attribute. As a result, `errors[:age_before_type_cast]` remains empty in normal usage. -### Combining `validates_raw` and `validates` on the same attribute +### Combining `validates_raw` and `validates` -You can use both on the same attribute. +You can use both on the same attribute. When both fail, each message is added to the same attribute key. ```ruby class UserParams < StructuredParams::Params @@ -62,12 +69,9 @@ params.errors[:score] # => includes errors from both validates_raw and validates ``` -When both validations fail, both messages are added to the same attribute (`:score`). -The exact typed-validation message depends on your validator options and I18n locale. - ## Nested Validation -Validation automatically cascades to nested objects and arrays: +Calling `valid?` on a parent object automatically cascades validation to all nested objects and arrays. Errors are aggregated using dot notation (e.g. `address.postal_code`, `hobbies.0.name`). ```ruby class AddressParams < StructuredParams::Params @@ -98,4 +102,4 @@ class UserParams < StructuredParams::Params end ``` -When you call `valid?` on the parent object, it automatically validates all nested objects and arrays. Errors from nested objects are aggregated with dot notation (e.g., `address.postal_code`, `hobbies.0.name`). +Each element of the `hobbies` array is validated automatically when `valid?` is called — no `validates` declaration is needed on the parent class for array attributes. diff --git a/lib/structured_params/i18n.rb b/lib/structured_params/i18n.rb index 9b0b3b8..2c1dfcc 100644 --- a/lib/structured_params/i18n.rb +++ b/lib/structured_params/i18n.rb @@ -12,7 +12,7 @@ module StructuredParams # # == i18n keys # - # You can customise how array indices and object nesting are rendered by + # You can customize how array indices and object nesting are rendered by # defining the following keys in your locale file: # # ja: From 5f17879f22dbbc034555d5152a4733e9723ebca1 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 05:40:53 +0900 Subject: [PATCH 07/15] Update README and README_ja to reflect changes in documentation structure and improve clarity on form objects --- README.md | 3 +-- README_ja.md | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 110c556..8d65c7f 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,7 @@ end - **[Strong Parameters](docs/strong-parameters.md)** - Automatic permit list generation - **[Error Handling](docs/error-handling.md)** - Flat and structured error formats - **[Serialization](docs/serialization.md)** - Converting parameters to hashes and JSON -- **[Gem Comparison](docs/comparison.md)** - Comparison with typed_params, dry-validation, and reform - +- **[Form Objects](docs/form-objects.md)** - Form object pattern with Rails views ## Contributing diff --git a/README_ja.md b/README_ja.md index 0e177a5..21decbc 100644 --- a/README_ja.md +++ b/README_ja.md @@ -65,16 +65,17 @@ def create end ``` -#### 型変換前の生入力をバリデーションする +#### プリミティブ配列 -ActiveModel の型変換前の入力値を検証したい場合は `validates_raw` を使います。 +`value_type` を使うとプリミティブ型の配列を扱えます。Strong Parameters では配列フォーマット(`tags: []`)で許可されます。 ```ruby class UserParams < StructuredParams::Params - attribute :age, :integer - - validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' } + attribute :tags, :array, value_type: :string end + +# 相当する Strong Parameters: +# params.permit(tags: []) ``` ### 2. フォームオブジェクト @@ -106,11 +107,11 @@ end - **[インストールとセットアップ](docs/installation.md)** - StructuredParams の始め方 - **[基本的な使用方法](docs/basic-usage.md)** - パラメータクラス、ネストオブジェクト、配列 -- **[バリデーション](docs/validation.md)** - ネスト構造でのActiveModelバリデーション使用 -- **[Strong Parameters](docs/strong-parameters.md)** - 自動permit リスト生成 +- **[バリデーション](docs/validation.md)** - ネスト構造での ActiveModel バリデーション使用 +- **[Strong Parameters](docs/strong-parameters.md)** - 自動 permit リスト生成 - **[エラーハンドリング](docs/error-handling.md)** - フラットと構造化エラーフォーマット -- **[シリアライゼーション](docs/serialization.md)** - パラメータのハッシュとJSON変換 -- **[Gem比較](docs/comparison.md)** - typed_params、dry-validation、reformとの比較 +- **[シリアライゼーション](docs/serialization.md)** - パラメータのハッシュと JSON 変換 +- **[フォームオブジェクト](docs/form-objects.md)** - Rails ビューとのフォームオブジェクトパターン ## コントリビューション From a18b895377d1611a1d48f35f46259e8fbb2fcfd2 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 05:41:02 +0900 Subject: [PATCH 08/15] Fix typo in i18n.rbs: change "customise" to "customize" for consistency --- sig/structured_params/i18n.rbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sig/structured_params/i18n.rbs b/sig/structured_params/i18n.rbs index cf07afc..ca3333c 100644 --- a/sig/structured_params/i18n.rbs +++ b/sig/structured_params/i18n.rbs @@ -11,7 +11,7 @@ module StructuredParams # # == i18n keys # - # You can customise how array indices and object nesting are rendered by + # You can customize how array indices and object nesting are rendered by # defining the following keys in your locale file: # # ja: From 8d66e6638ab17c9a869dc0db79364fce8e52652c Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 05:47:30 +0900 Subject: [PATCH 09/15] Exclude spec files from RbsInline/MissingTypeAnnotation enforcement in RuboCop configuration --- .rubocop_rbs.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.rubocop_rbs.yml b/.rubocop_rbs.yml index e5b095f..5767a86 100644 --- a/.rubocop_rbs.yml +++ b/.rubocop_rbs.yml @@ -7,7 +7,8 @@ plugins: # ========= RBS =========== Style/RbsInline/MissingTypeAnnotation: EnforcedStyle: method_type_signature - + Exclude: + - 'spec/**/*' Style/RbsInline/UntypedInstanceVariable: Exclude: - 'lib/structured_params/params.rb' From ee45f27665ef75979e2f3b6e90fbce09d5cb1e77 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 05:48:08 +0900 Subject: [PATCH 10/15] Update spec/support/shared_contexts/ja_locale.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- spec/support/shared_contexts/ja_locale.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/spec/support/shared_contexts/ja_locale.rb b/spec/support/shared_contexts/ja_locale.rb index 6e6ed66..281f2a0 100644 --- a/spec/support/shared_contexts/ja_locale.rb +++ b/spec/support/shared_contexts/ja_locale.rb @@ -9,11 +9,15 @@ I18n.enforce_available_locales = false I18n.backend.reload! load_ja_locale_files(ja_locale_files) - I18n.with_locale(:ja) do - example.run + + begin + I18n.with_locale(:ja) do + example.run + end + ensure + I18n.enforce_available_locales = original_enforce + I18n.backend.reload! end - I18n.enforce_available_locales = original_enforce - I18n.backend.reload! end def load_ja_locale_files(locale_files) From 4afd4c43183d6f5d1c6056fa6ea369c0ac457735 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 05:56:06 +0900 Subject: [PATCH 11/15] Exclude test classes from Style/OneClassPerFile enforcement in RuboCop configuration --- .rubocop.yml | 4 ++++ spec/support/test_classes/form_object_classes.rb | 4 ---- spec/support/test_classes/params_classes.rb | 4 ---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index f7a8af6..6472f10 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,6 +6,10 @@ plugins: AllCops: NewCops: enable +Style/OneClassPerFile: + Exclude: + - spec/support/test_classes/**/* + Layout/LeadingCommentSpace: Enabled: false diff --git a/spec/support/test_classes/form_object_classes.rb b/spec/support/test_classes/form_object_classes.rb index 21f17f6..9a8d5d2 100644 --- a/spec/support/test_classes/form_object_classes.rb +++ b/spec/support/test_classes/form_object_classes.rb @@ -2,8 +2,6 @@ require 'uri' -# rubocop:disable Style/OneClassPerFile - # Classes primarily used by: # - spec/form_object_spec.rb # - spec/permit_spec.rb (UserRegistrationForm, Admin::NamespacedForm) @@ -51,5 +49,3 @@ class RegistrationForm < StructuredParams::Params end end end - -# rubocop:enable Style/OneClassPerFile diff --git a/spec/support/test_classes/params_classes.rb b/spec/support/test_classes/params_classes.rb index 1ebd275..0f406ab 100644 --- a/spec/support/test_classes/params_classes.rb +++ b/spec/support/test_classes/params_classes.rb @@ -2,8 +2,6 @@ require 'uri' -# rubocop:disable Style/OneClassPerFile - # Classes primarily used by: # - spec/params_spec.rb # - spec/i18n_spec.rb @@ -63,5 +61,3 @@ class TeamMemberParameter < StructuredParams::Params class OrganizationParameter < StructuredParams::Params attribute :team, :array, value_class: TeamMemberParameter end - -# rubocop:enable Style/OneClassPerFile From a0ab34febae697ac18329663a5407766376be9a4 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 05:58:13 +0900 Subject: [PATCH 12/15] Update pre-commit hook to run RuboCop from bin directory --- lefthook.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lefthook.yml b/lefthook.yml index 0f5272b..c2c21fe 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -6,7 +6,7 @@ prepare-commit-msg: pre-commit: commands: rubocop: - run: bundle exec rubocop + run: ./bin/rubocop rspec: run: bundle exec rspec steep: From b3a71e28bf4bfceddf9d924c87555a6cdc0eea94 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 06:09:53 +0900 Subject: [PATCH 13/15] Enhance human_attribute_name to support explicit locale options for nested attributes --- lib/structured_params/i18n.rb | 37 +++++++++++++++++++++---------- spec/i18n_spec.rb | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/lib/structured_params/i18n.rb b/lib/structured_params/i18n.rb index 2c1dfcc..7b564c4 100644 --- a/lib/structured_params/i18n.rb +++ b/lib/structured_params/i18n.rb @@ -46,7 +46,7 @@ def human_attribute_name(attribute, options = {}) return super if parts.length == 1 return super unless structured_attributes.key?(parts.first) - resolve_nested_human_attribute_name(parts) + resolve_nested_human_attribute_name(parts, options) end private @@ -54,14 +54,20 @@ def human_attribute_name(attribute, options = {}) # Walk +parts+ (e.g. ["hobbies", "0", "name"]) and build a human-readable # label by delegating each segment to the appropriate nested class. # - #: (Array[String]) -> String - def resolve_nested_human_attribute_name(parts) + # Only +:locale+ is forwarded to inner +human_attribute_name+ calls. + # Options such as +:default+ are specific to the outer call (e.g. from + # +full_messages+) and must not bleed into individual segment lookups, + # where they would replace the segment's own translation fallback. + # + #: (Array[String], Hash[untyped, untyped]) -> String + def resolve_nested_human_attribute_name(parts, options) label = nil klass = self + inner_opts = options.slice(:locale) attr_segments(parts).each do |index, attr| - human = klass&.human_attribute_name(attr) || attr.humanize - label = build_nested_label(label, index, human) + human = klass&.human_attribute_name(attr, inner_opts) || attr.humanize + label = build_nested_label(label, index, human, options) klass &&= klass.structured_attributes[attr] end @@ -93,24 +99,31 @@ def attr_segments(parts) # activemodel.errors.nested_attribute.array (parent, index, child) # activemodel.errors.nested_attribute.object (parent, child) # - #: (String?, String?, String) -> String - def build_nested_label(result, index, attr_human) - if result.nil? - attr_human - elsif index + # The +locale:+ key from +options+ is forwarded to ::I18n.t so that an + # explicit locale passed to human_attribute_name is honoured. + # + #: (String?, String?, String, Hash[untyped, untyped]) -> String + def build_nested_label(result, index, attr_human, options) + return attr_human if result.nil? + + i18n_opts = options.slice(:locale) + + if index ::I18n.t( 'activemodel.errors.nested_attribute.array', parent: result, index: index, child: attr_human, - default: "#{result} #{index} #{attr_human}" + default: "#{result} #{index} #{attr_human}", + **i18n_opts ) else ::I18n.t( 'activemodel.errors.nested_attribute.object', parent: result, child: attr_human, - default: "#{result} #{attr_human}" + default: "#{result} #{attr_human}", + **i18n_opts ) end end diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb index 4cdf8b6..9a8291d 100644 --- a/spec/i18n_spec.rb +++ b/spec/i18n_spec.rb @@ -190,4 +190,45 @@ end end end + + describe 'explicit locale: option threading' do + include_context 'with ja locale' + + let(:ja_overrides) do + { + activemodel: { + errors: { + nested_attribute: { + array: '%s %s 番目の%s', + object: '%sの%s' + } + } + } + } + end + + context 'when locale: :ja is passed while the current locale is :en' do + it 'resolves array attribute labels in ja' do + I18n.with_locale(:en) do + expect(UserParameter.human_attribute_name(:'hobbies.0.name', locale: :ja)).to eq('趣味 0 番目の名前') + end + end + + it 'resolves object attribute labels in ja' do + I18n.with_locale(:en) do + expect(UserParameter.human_attribute_name(:'address.postal_code', locale: :ja)).to eq('住所の郵便番号') + end + end + end + + context 'when locale: :en is passed while the current locale is :ja' do + it 'resolves array attribute labels in en' do + expect(UserParameter.human_attribute_name(:'hobbies.0.name', locale: :en)).to eq('Hobbies 0 Name') + end + + it 'resolves object attribute labels in en' do + expect(UserParameter.human_attribute_name(:'address.postal_code', locale: :en)).to eq('Address Postal code') + end + end + end end From db8b7c3d193cecb1ac52eb0a3930cd4e41e234cc Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 06:10:51 +0900 Subject: [PATCH 14/15] Update i18n.rbs to forward locale options in nested human attribute methods --- sig/structured_params/i18n.rbs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/sig/structured_params/i18n.rbs b/sig/structured_params/i18n.rbs index ca3333c..ae44376 100644 --- a/sig/structured_params/i18n.rbs +++ b/sig/structured_params/i18n.rbs @@ -46,8 +46,13 @@ module StructuredParams # Walk +parts+ (e.g. ["hobbies", "0", "name"]) and build a human-readable # label by delegating each segment to the appropriate nested class. # - # : (Array[String]) -> String - def resolve_nested_human_attribute_name: (Array[String]) -> String + # Only +:locale+ is forwarded to inner +human_attribute_name+ calls. + # Options such as +:default+ are specific to the outer call (e.g. from + # +full_messages+) and must not bleed into individual segment lookups, + # where they would replace the segment's own translation fallback. + # + # : (Array[String], Hash[untyped, untyped]) -> String + def resolve_nested_human_attribute_name: (Array[String], Hash[untyped, untyped]) -> String # Convert a parts array into (index_or_nil, attr) pairs. # @@ -64,7 +69,10 @@ module StructuredParams # activemodel.errors.nested_attribute.array (parent, index, child) # activemodel.errors.nested_attribute.object (parent, child) # - # : (String?, String?, String) -> String - def build_nested_label: (String?, String?, String) -> String + # The +locale:+ key from +options+ is forwarded to ::I18n.t so that an + # explicit locale passed to human_attribute_name is honoured. + # + # : (String?, String?, String, Hash[untyped, untyped]) -> String + def build_nested_label: (String?, String?, String, Hash[untyped, untyped]) -> String end end From f6fd2af59fbb036696f9a5ea4d6ddea1ccc32554 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 10 Apr 2026 06:12:18 +0900 Subject: [PATCH 15/15] Update lefthook.yml to automatically add generated signatures to git staging --- lefthook.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lefthook.yml b/lefthook.yml index c2c21fe..9bf5464 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,7 +1,7 @@ prepare-commit-msg: commands: rbs-inline: - run: bundle exec rbs-inline --output=sig lib/**/*.rb + run: bundle exec rbs-inline --output=sig lib/**/*.rb && git add sig/ pre-commit: commands: