From 09c9c5cd6df318795e84cd160428661b1974e5f5 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Thu, 2 Apr 2026 04:19:20 +0900 Subject: [PATCH 01/12] Add operational guide for agents working with structured_params --- AGENTS.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f85e0a8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# AGENTS.md + +このリポジトリで作業するエージェント向けの運用ガイドです。変更前に全体像を確認し、最小差分で修正してください。 + +## プロジェクト概要 + +- `structured_params` は Rails 向けの型付きパラメータ/フォームオブジェクト用 gem です。 +- コア実装は `lib/structured_params/` 配下にあります。 +- テストは RSpec、Lint は RuboCop、型検査は Steep を使います。 +- RBS シグネチャは `rbs-inline` から生成されます。 + +## 主要ディレクトリ + +- `lib/structured_params.rb`: エントリーポイント +- `lib/structured_params/params.rb`: コアの `Params` 実装 +- `lib/structured_params/type/`: `array` / `object` 型ハンドラ +- `spec/`: RSpec テスト +- `docs/`: 利用者向けドキュメント +- `sig/`: `rbs-inline` により生成された RBS + +## セットアップと実行 + +初回セットアップ: + +```bash +bin/setup +``` + +通常の確認: + +```bash +bundle exec rspec +bundle exec rubocop +bundle exec steep check +``` + +Ruby 3.2 系では `steep` や `.rubocop_rbs.yml` 前提のチェックが使えない場合があります。`Rakefile` は Ruby 3.3+ でのみ `steep` をデフォルトタスクに含めます。 + +個別実行例: + +```bash +bundle exec rspec spec/params_spec.rb +bundle exec rubocop lib/structured_params/params.rb +``` + +## 変更時のルール + +- `sig/**/*.rbs` は手編集しないでください。必要なら `bundle exec rbs-inline --output=sig lib/**/*.rb` で再生成します。 +- Ruby メソッドの型注釈は `rbs-inline` の `method_type_signature` スタイルを使ってください。 +- インスタンス変数の型注釈は `# @rbs` コメントを使ってください。 +- 既存の公開 API を変える場合は、README と `docs/` の整合も確認してください。 +- Strong Parameters、ネストした object/array、エラー整形は回帰しやすいので重点的に確認してください。 + +## テスト方針 + +- 振る舞い変更には RSpec を追加または更新してください。 +- 既存 spec のスタイルに合わせ、必要に応じて `spec/factories/` と `spec/support/` を再利用してください。 +- 修正が型やシリアライズ、permit 生成に関わる場合は、関連 spec を広めに実行してください。 + +## コミット前チェック + +`lefthook.yml` によりコミット前に以下が走ります。 + +- `bundle exec rbs-inline --output=sig lib/**/*.rb` +- `bundle exec rubocop` +- `bundle exec rspec` +- `bundle exec steep check` + +フックで失敗しない状態まで揃えてから完了扱いにしてください。 + +## ドキュメント更新の目安 + +以下を変えた場合はドキュメント更新を検討してください。 + +- 新しい attribute オプションや型の追加 +- permit 挙動の変更 +- エラーフォーマットやバリデーション挙動の変更 +- Rails / Ruby サポート範囲の変更 + +利用者向け概要は `README.md`、詳細仕様は `docs/*.md`、日本語 README が必要なら `README_ja.md` も更新します。 From 4bddc16bc2a5dbaf3f1eff99c52778c7a6f475e4 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 3 Apr 2026 01:50:09 +0900 Subject: [PATCH 02/12] Add operational guide for agents working with structured_params --- lib/structured_params/attribute_methods.rb | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 lib/structured_params/attribute_methods.rb diff --git a/lib/structured_params/attribute_methods.rb b/lib/structured_params/attribute_methods.rb new file mode 100644 index 0000000..fc5ddff --- /dev/null +++ b/lib/structured_params/attribute_methods.rb @@ -0,0 +1,29 @@ +# rbs_inline: enabled +# frozen_string_literal: true + +module StructuredParams + # Extends ActiveModel::Attributes to define +attr_before_type_cast+ accessors + # for each attribute, mirroring ActiveRecord::AttributeMethods::BeforeTypeCast. + # + # Example: + # class UserParams < StructuredParams::Params + # attribute :age, :integer + # end + # + # params = UserParams.new(age: "42abc") + # params.age # => 42 (type-cast) + # params.age_before_type_cast # => "42abc" (raw input) + module AttributeMethods + extend ActiveSupport::Concern + + included do + # Override attribute to also define `attr_before_type_cast` + # via ActiveModel::Attribute#value_before_type_cast + #: (String | Symbol name, *untyped) -> void + def self.attribute(name, ...) + super + define_method(:"#{name}_before_type_cast") { @attributes[name.to_s].value_before_type_cast } + end + end + end +end From ec04eb14028fce4df7ff3f84471b636c1981fc58 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 3 Apr 2026 01:52:10 +0900 Subject: [PATCH 03/12] Add before_type_cast readers for attributes in StructuredParams --- lib/structured_params/attribute_methods.rb | 2 +- sig/structured_params/attribute_methods.rbs | 23 ++++++++++++ spec/attribute_methods_spec.rb | 40 +++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 sig/structured_params/attribute_methods.rbs create mode 100644 spec/attribute_methods_spec.rb diff --git a/lib/structured_params/attribute_methods.rb b/lib/structured_params/attribute_methods.rb index fc5ddff..55cc00f 100644 --- a/lib/structured_params/attribute_methods.rb +++ b/lib/structured_params/attribute_methods.rb @@ -19,7 +19,7 @@ module AttributeMethods included do # Override attribute to also define `attr_before_type_cast` # via ActiveModel::Attribute#value_before_type_cast - #: (String | Symbol name, *untyped) -> void + #: (Symbol name, *untyped) -> void def self.attribute(name, ...) super define_method(:"#{name}_before_type_cast") { @attributes[name.to_s].value_before_type_cast } diff --git a/sig/structured_params/attribute_methods.rbs b/sig/structured_params/attribute_methods.rbs new file mode 100644 index 0000000..c35d26a --- /dev/null +++ b/sig/structured_params/attribute_methods.rbs @@ -0,0 +1,23 @@ +# Generated from lib/structured_params/attribute_methods.rb with RBS::Inline + +module StructuredParams + # Extends ActiveModel::Attributes to define +attr_before_type_cast+ accessors + # for each attribute, mirroring ActiveRecord::AttributeMethods::BeforeTypeCast. + # + # Example: + # class UserParams < StructuredParams::Params + # attribute :age, :integer + # end + # + # params = UserParams.new(age: "42abc") + # params.age # => 42 (type-cast) + # params.age_before_type_cast # => "42abc" (raw input) + module AttributeMethods + extend ActiveSupport::Concern + + # Override attribute to also define `attr_before_type_cast` + # via ActiveModel::Attribute#value_before_type_cast + # : (Symbol name, *untyped) -> void + def self.attribute: (Symbol name, *untyped) -> void + end +end diff --git a/spec/attribute_methods_spec.rb b/spec/attribute_methods_spec.rb new file mode 100644 index 0000000..adb0a7d --- /dev/null +++ b/spec/attribute_methods_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +# rbs_inline: enabled + +require 'spec_helper' + +RSpec.describe StructuredParams::AttributeMethods do + describe 'before_type_cast readers' do + context 'with primitive attribute' do + subject(:params) { UserParameter.new(age: '42abc') } + + it 'returns type-cast value from reader' do + expect(params.age).to eq(42) + end + + it 'returns raw input from before_type_cast reader' do + expect(params.age_before_type_cast).to eq('42abc') + end + end + + context 'with nil input' do + subject(:params) { UserParameter.new(age: nil) } + + it 'returns nil from before_type_cast reader' do + expect(params.age_before_type_cast).to be_nil + end + end + + context 'with string attribute' do + subject(:params) { UserParameter.new(name: 'Tanaka Taro') } + + it 'defines before_type_cast reader for declared attribute' do + expect(params).to respond_to(:name_before_type_cast) + end + + it 'returns same value for string type' do + expect(params.name_before_type_cast).to eq('Tanaka Taro') + end + end + end +end From d52d3c461e678a46054d0986956f1fa2fc811738 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 3 Apr 2026 03:00:26 +0900 Subject: [PATCH 04/12] Add configuration file and RSpec skill documentation for testing --- .agents/skills/rspec/SKILL.md | 37 +++++++++++++++++++++++++++++++++++ .codex/config.toml | 2 ++ 2 files changed, 39 insertions(+) create mode 100644 .agents/skills/rspec/SKILL.md create mode 100644 .codex/config.toml diff --git a/.agents/skills/rspec/SKILL.md b/.agents/skills/rspec/SKILL.md new file mode 100644 index 0000000..44ac2c4 --- /dev/null +++ b/.agents/skills/rspec/SKILL.md @@ -0,0 +1,37 @@ +--- +name: rspec +description: Run and debug RSpec tests for the structured_params gem. Use when adding features, fixing bugs, or validating behavior changes in specs under spec/. +--- + +# RSpec Skill + +Run tests with minimal scope first, then widen only when needed. + +## Core Commands + +```bash +bundle exec rspec spec/attribute_methods_spec.rb +bundle exec rspec spec/params_spec.rb +bundle exec rspec +``` + +## Workflow + +1. Run the most specific spec file related to the change. +2. If failures include shared behavior, run adjacent spec files. +3. Run full `bundle exec rspec` before finishing if behavior changed broadly. + +## Failure Triage + +- `NoMethodError` around params fields: + Check `lib/structured_params/params.rb` and `lib/structured_params/attribute_methods.rb`. +- Nested error path mismatch: + Check `lib/structured_params/errors.rb` and structured validation behavior. +- Type-cast related mismatch: + Confirm expected value vs `*_before_type_cast` usage in specs. + +## Repository Notes + +- Test helpers/classes are loaded from `spec/support/test_classes.rb`. +- Factory objects live under `spec/factories/`. +- This project uses `bundle exec` for all test commands. diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..9d7012c --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,2 @@ +approval_policy = "on-request" +sandbox_mode = "workspace-write" From c852ad81cf63ac5dae9e384750725e8438fcc5ee Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 3 Apr 2026 04:34:52 +0900 Subject: [PATCH 05/12] Add raw parameter validation support to StructuredParams --- lib/structured_params/validations.rb | 71 ++++++++++++++++ sig/structured_params/validations.rbs | 44 ++++++++++ spec/validations_spec.rb | 116 ++++++++++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 lib/structured_params/validations.rb create mode 100644 sig/structured_params/validations.rbs create mode 100644 spec/validations_spec.rb diff --git a/lib/structured_params/validations.rb b/lib/structured_params/validations.rb new file mode 100644 index 0000000..a0435fc --- /dev/null +++ b/lib/structured_params/validations.rb @@ -0,0 +1,71 @@ +# rbs_inline: enabled +# frozen_string_literal: true + +module StructuredParams + # Provides +validates_raw+ which validates raw parameter values before type casting. + # + # Internally delegates to ActiveModel's +validates+ on the +_before_type_cast+ + # attribute, then remaps errors back to the original attribute name. + # This means all standard ActiveModel validators (format, numericality, etc.) + # work as-is on the raw input value. + # + # Example: + # class UserParams < StructuredParams::Params + # attribute :age, :integer + # validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' } + # end + # + # params = UserParams.new(age: "abc") + # params.valid? # => false + # params.errors[:age] # => ["must be numeric string"] + module Validations + extend ActiveSupport::Concern + + included do + class_attribute :validates_raw_btc_map, instance_accessor: false, default: {} + class_attribute :validates_raw_remap_validator_installed, instance_accessor: false, default: false + end + + class_methods do + # Validates raw attribute value before type casting. + # + # Accepts the same options as +validates+ (format, numericality, presence, etc.), + # but validates the raw input value before it is converted by ActiveModel::Attributes. + # + # Examples: + # validates_raw :age, format: { with: /\A\d+\z/ } + # validates_raw :score, numericality: { only_integer: true } + # validates_raw :code, format: { with: /\A[A-Z]+\z/, message: 'must be uppercase' } + # + #: (*Symbol, **untyped) -> void + def validates_raw(*attr_names, **options) + btc_map = attr_names.to_h { |attr| [attr.to_sym, :"#{attr}_before_type_cast"] } + validates(*btc_map.values, **options) + self.validates_raw_btc_map = validates_raw_btc_map.merge(btc_map) + validates_raw_install_remap_validator_once + end + + #: () -> void + def validates_raw_install_remap_validator_once + return if validates_raw_remap_validator_installed + + set_callback(:validate, :after, :validates_raw_remap_errors) + + self.validates_raw_remap_validator_installed = true + end + private :validates_raw_install_remap_validator_once + end + + private + + #: () -> void + def validates_raw_remap_errors + self.class.validates_raw_btc_map.each do |attr, btc| + next if errors.where(btc).none? + + errors.where(btc).dup.each { |e| errors.add(attr, e.message) } + errors.delete(btc) + end + end + end +end diff --git a/sig/structured_params/validations.rbs b/sig/structured_params/validations.rbs new file mode 100644 index 0000000..755121f --- /dev/null +++ b/sig/structured_params/validations.rbs @@ -0,0 +1,44 @@ +# Generated from lib/structured_params/validations.rb with RBS::Inline + +module StructuredParams + # Provides +validates_raw+ which validates raw parameter values before type casting. + # + # Internally delegates to ActiveModel's +validates+ on the +_before_type_cast+ + # attribute, then remaps errors back to the original attribute name. + # This means all standard ActiveModel validators (format, numericality, etc.) + # work as-is on the raw input value. + # + # Example: + # class UserParams < StructuredParams::Params + # attribute :age, :integer + # validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' } + # end + # + # params = UserParams.new(age: "abc") + # params.valid? # => false + # params.errors[:age] # => ["must be numeric string"] + module Validations + extend ActiveSupport::Concern + + # Validates raw attribute value before type casting. + # + # Accepts the same options as +validates+ (format, numericality, presence, etc.), + # but validates the raw input value before it is converted by ActiveModel::Attributes. + # + # Examples: + # validates_raw :age, format: { with: /\A\d+\z/ } + # validates_raw :score, numericality: { only_integer: true } + # validates_raw :code, format: { with: /\A[A-Z]+\z/, message: 'must be uppercase' } + # + # : (*Symbol, **untyped) -> void + def validates_raw: (*Symbol, **untyped) -> void + + # : () -> void + def validates_raw_install_remap_validator_once: () -> void + + private + + # : () -> void + def validates_raw_remap_errors: () -> void + end +end diff --git a/spec/validations_spec.rb b/spec/validations_spec.rb new file mode 100644 index 0000000..de8fb37 --- /dev/null +++ b/spec/validations_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe StructuredParams::Validations do + describe '.validates_raw' do + context 'with single attribute' do + subject(:params) { StrictAgeParameter.new(age:) } + + context 'when raw value is valid' do + let(:age) { '12' } + + it { is_expected.to be_valid } + end + + context 'when raw value is invalid' do + let(:age) { '12x' } + + it 'adds error to original attribute name' do + expect(params).not_to be_valid + expect(params.errors[:age]).to include('must be numeric string') + expect(params.errors[:age_before_type_cast]).to be_empty + end + end + end + + context 'with multiple attributes' do + subject(:params) { params_class.new(code:, score:) } + + let(:params_class) do + stub_const('RawMultiParameter', Class.new(StructuredParams::Params) do + attribute :code, :string + attribute :score, :integer + + validates_raw :code, :score, format: { with: /\A\d+\z/, message: 'must be numeric string' } + end) + end + let(:code) { 'A12' } + let(:score) { '3x' } + + it 'remaps each before_type_cast error to original attribute' do + expect(params).not_to be_valid + expect(params.errors[:code]).to include('must be numeric string') + expect(params.errors[:score]).to include('must be numeric string') + expect(params.errors[:code_before_type_cast]).to be_empty + expect(params.errors[:score_before_type_cast]).to be_empty + end + end + + context 'when declared multiple times' do + subject(:params) { params_class.new(code:, score:) } + + let(:params_class) do + stub_const('RawValidationDeclaredMultipleTimesParameter', Class.new(StructuredParams::Params) do + attribute :code, :string + attribute :score, :integer + + validates_raw :code, format: { with: /\A\d+\z/, message: 'code must be numeric string' } + validates_raw :score, format: { with: /\A\d+\z/, message: 'score must be numeric string' } + end) + end + let(:code) { 'A12' } + let(:score) { '3x' } + + it 'remaps before_type_cast errors for each declaration' do + expect(params).not_to be_valid + expect(params.errors[:code]).to include('code must be numeric string') + expect(params.errors[:score]).to include('score must be numeric string') + expect(params.errors[:code_before_type_cast]).to be_empty + expect(params.errors[:score_before_type_cast]).to be_empty + end + end + + context 'when combined with validates on the same attribute' do + let(:age) { 'abc' } + + context 'when validates is declared before validates_raw' do + subject(:params) { params_class.new(age:) } + + let(:params_class) do + stub_const('CombinedValidationTypedFirstParameter', Class.new(StructuredParams::Params) do + attribute :age, :integer + + validates :age, numericality: { greater_than: 10, message: 'typed' } + validates_raw :age, format: { with: /\A\d+\z/, message: 'raw' } + end) + end + + it 'collects both errors on the original attribute' do + expect(params).not_to be_valid + expect(params.errors[:age]).to contain_exactly('typed', 'raw') + expect(params.errors[:age_before_type_cast]).to be_empty + end + end + + context 'when validates_raw is declared before validates' do + subject(:params) { params_class.new(age:) } + + let(:params_class) do + stub_const('CombinedValidationRawFirstParameter', Class.new(StructuredParams::Params) do + attribute :age, :integer + + validates_raw :age, format: { with: /\A\d+\z/, message: 'raw' } + validates :age, numericality: { greater_than: 10, message: 'typed' } + end) + end + + it 'collects both errors on the original attribute' do + expect(params).not_to be_valid + expect(params.errors[:age]).to contain_exactly('typed', 'raw') + expect(params.errors[:age_before_type_cast]).to be_empty + end + end + end + end +end From 6961b3b405d611ec30f41b18d2765c111b7a6e79 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 3 Apr 2026 04:35:27 +0900 Subject: [PATCH 06/12] Add raw validation support for StrictAgeParameter --- lib/structured_params.rb | 2 ++ lib/structured_params/params.rb | 2 ++ sig/structured_params/params.rbs | 4 ++++ spec/params_spec.rb | 21 +++++++++++++++++++++ spec/support/test_classes.rb | 6 ++++++ 5 files changed, 35 insertions(+) diff --git a/lib/structured_params.rb b/lib/structured_params.rb index 8311ea2..0571214 100644 --- a/lib/structured_params.rb +++ b/lib/structured_params.rb @@ -10,6 +10,8 @@ # errors require_relative 'structured_params/errors' +require_relative 'structured_params/attribute_methods' +require_relative 'structured_params/validations' # types (load first for module definition) require_relative 'structured_params/type/object' diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index 689fe95..4d279bf 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -49,6 +49,8 @@ module StructuredParams class Params include ActiveModel::Model include ActiveModel::Attributes + include AttributeMethods + include Validations # @rbs @errors: ::StructuredParams::Errors? diff --git a/sig/structured_params/params.rbs b/sig/structured_params/params.rbs index 1af4b63..280d971 100644 --- a/sig/structured_params/params.rbs +++ b/sig/structured_params/params.rbs @@ -50,6 +50,10 @@ module StructuredParams include ActiveModel::Attributes + include AttributeMethods + + include Validations + @errors: ::StructuredParams::Errors? self.@structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]? diff --git a/spec/params_spec.rb b/spec/params_spec.rb index fd06cc3..6e5516b 100644 --- a/spec/params_spec.rb +++ b/spec/params_spec.rb @@ -309,6 +309,27 @@ end end + describe 'raw validations' do + describe '#valid?' do + context 'when value matches raw format' do + subject(:params) { StrictAgeParameter.new(age: '12') } + + it 'is valid' do + expect(params).to be_valid + end + end + + context 'when value fails raw format but would be castable' do + subject(:params) { StrictAgeParameter.new(age: '12x') } + + it 'adds validation error on raw input' do + expect(params).not_to be_valid + expect(params.errors[:age]).to include('must be numeric string') + end + end + end + end + describe 'edge cases' do subject(:user_param) { build(:user_parameter, **params) } diff --git a/spec/support/test_classes.rb b/spec/support/test_classes.rb index 83432ae..2e09882 100644 --- a/spec/support/test_classes.rb +++ b/spec/support/test_classes.rb @@ -57,4 +57,10 @@ class OrderParameters < StructuredParams::Params 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 From fe38d30c00cfcdd86687dd0f420e755d714e5e4a Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 3 Apr 2026 04:45:49 +0900 Subject: [PATCH 07/12] Add raw input validation support for score attribute in UserParams --- README.md | 4 ++++ README_ja.md | 16 ++++++++++++++ docs/validation.md | 39 +++++++++++++++++++++++++++++++++ lib/structured_params/params.rb | 4 ++++ 4 files changed, 63 insertions(+) diff --git a/README.md b/README.md index 70c9692..110c556 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,15 @@ end class UserParams < StructuredParams::Params attribute :name, :string attribute :age, :integer + attribute :score, :integer attribute :tags, :array, value_type: :string # Primitive array attribute :address, :object, value_class: AddressParams # Nested object + # validate raw string before type casting + validates_raw :score, format: { with: /\A\d+\z/, message: 'must be numeric string' } validates :name, presence: true validates :age, numericality: { greater_than: 0 } + validates :score, numericality: { greater_than_or_equal_to: 0 } end # Use in API controller diff --git a/README_ja.md b/README_ja.md index 9406530..0e177a5 100644 --- a/README_ja.md +++ b/README_ja.md @@ -41,11 +41,15 @@ end class UserParams < StructuredParams::Params attribute :name, :string attribute :age, :integer + attribute :score, :integer attribute :tags, :array, value_type: :string # プリミティブ配列 attribute :address, :object, value_class: AddressParams # ネストオブジェクト + # 型変換前の生文字列をバリデーション + validates_raw :score, format: { with: /\A\d+\z/, message: 'must be numeric string' } validates :name, presence: true validates :age, numericality: { greater_than: 0 } + validates :score, numericality: { greater_than_or_equal_to: 0 } end # API コントローラーで使用 @@ -61,6 +65,18 @@ def create end ``` +#### 型変換前の生入力をバリデーションする + +ActiveModel の型変換前の入力値を検証したい場合は `validates_raw` を使います。 + +```ruby +class UserParams < StructuredParams::Params + attribute :age, :integer + + validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' } +end +``` + ### 2. フォームオブジェクト ```ruby diff --git a/docs/validation.md b/docs/validation.md index 694e413..7420388 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -24,6 +24,45 @@ class UserParams < StructuredParams::Params end ``` +## Validate Raw Input (`validates_raw`) + +Use `validates_raw` to validate the original input value before type casting. + +```ruby +class UserParams < StructuredParams::Params + attribute :age, :integer + + validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' } +end + +params = UserParams.new(age: '12x') +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. + +### Combining `validates_raw` and `validates` on the same attribute + +You can use both on the same attribute. + +```ruby +class UserParams < StructuredParams::Params + attribute :score, :integer + + validates_raw :score, format: { with: /\A\d+\z/, message: 'must be numeric string' } + validates :score, numericality: { greater_than_or_equal_to: 0 } +end + +params = UserParams.new(score: 'abc') +params.valid? # => false +params.errors[:score] +# => includes both "must be numeric string" and "is not a number" +``` + +When both validations fail, both messages are added to the same attribute (`:score`). + ## Nested Validation Validation automatically cascades to nested objects and arrays: diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index 4d279bf..133265b 100644 --- a/lib/structured_params/params.rb +++ b/lib/structured_params/params.rb @@ -11,9 +11,13 @@ module StructuredParams # Strong Parameters example (API): # class UserParams < StructuredParams::Params # attribute :name, :string + # attribute :age, :integer # attribute :address, :object, value_class: AddressParams # attribute :hobbies, :array, value_class: HobbyParams # attribute :tags, :array, value_type: :string + # + # # Validate raw input before type casting (e.g., "12x" for integer fields) + # validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' } # end # # # In controller: From 786ba4eca82c9c5475be4d6022c5ab0edbd19f2b Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 3 Apr 2026 04:48:57 +0900 Subject: [PATCH 08/12] Add raw input validation for age attribute in UserParams --- docs/validation.md | 1 + sig/structured_params/params.rbs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/validation.md b/docs/validation.md index 7420388..dbc3929 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -32,6 +32,7 @@ Use `validates_raw` to validate the original input value before type casting. 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 diff --git a/sig/structured_params/params.rbs b/sig/structured_params/params.rbs index 280d971..d7673ba 100644 --- a/sig/structured_params/params.rbs +++ b/sig/structured_params/params.rbs @@ -10,9 +10,13 @@ module StructuredParams # Strong Parameters example (API): # class UserParams < StructuredParams::Params # attribute :name, :string + # attribute :age, :integer # attribute :address, :object, value_class: AddressParams # attribute :hobbies, :array, value_class: HobbyParams # attribute :tags, :array, value_type: :string + # + # # Validate raw input before type casting (e.g., "12x" for integer fields) + # validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' } # end # # # In controller: From 01e2db6d63b4a4a4e0ff2ac95279f155f9a40041 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 3 Apr 2026 04:56:09 +0900 Subject: [PATCH 09/12] Remove rbs_inline comments from parameter files and specs --- .rubocop_rbs.yml | 4 ++++ spec/attribute_methods_spec.rb | 1 - spec/errors_spec.rb | 1 - spec/factories/address_parameters.rb | 1 - spec/factories/hobby_parameters.rb | 1 - spec/factories/user_parameters.rb | 1 - spec/form_object_spec.rb | 1 - spec/params_spec.rb | 1 - spec/permit_spec.rb | 1 - spec/spec_helper.rb | 1 - spec/support/test_classes.rb | 1 - spec/type/array_spec.rb | 1 - spec/type/object_spec.rb | 1 - 13 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.rubocop_rbs.yml b/.rubocop_rbs.yml index d2a194d..e5b095f 100644 --- a/.rubocop_rbs.yml +++ b/.rubocop_rbs.yml @@ -11,3 +11,7 @@ Style/RbsInline/MissingTypeAnnotation: Style/RbsInline/UntypedInstanceVariable: Exclude: - 'lib/structured_params/params.rb' + +Style/RbsInline/RequireRbsInlineComment: + Exclude: + - 'spec/**/*' diff --git a/spec/attribute_methods_spec.rb b/spec/attribute_methods_spec.rb index adb0a7d..7e2f06a 100644 --- a/spec/attribute_methods_spec.rb +++ b/spec/attribute_methods_spec.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rbs_inline: enabled require 'spec_helper' diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index 52640ef..a23d234 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rbs_inline: enabled require 'spec_helper' diff --git a/spec/factories/address_parameters.rb b/spec/factories/address_parameters.rb index 6cbf531..c7e3127 100644 --- a/spec/factories/address_parameters.rb +++ b/spec/factories/address_parameters.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rbs_inline: enabled class AddressParameter < StructuredParams::Params attribute :postal_code, :string diff --git a/spec/factories/hobby_parameters.rb b/spec/factories/hobby_parameters.rb index 4385f5b..f9c4c3f 100644 --- a/spec/factories/hobby_parameters.rb +++ b/spec/factories/hobby_parameters.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rbs_inline: enabled class HobbyParameter < StructuredParams::Params attribute :name, :string diff --git a/spec/factories/user_parameters.rb b/spec/factories/user_parameters.rb index c234127..5d27d1f 100644 --- a/spec/factories/user_parameters.rb +++ b/spec/factories/user_parameters.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rbs_inline: enabled class UserParameter < StructuredParams::Params attribute :name, :string diff --git a/spec/form_object_spec.rb b/spec/form_object_spec.rb index 2e6975a..d77337e 100644 --- a/spec/form_object_spec.rb +++ b/spec/form_object_spec.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rbs_inline: enabled require 'spec_helper' diff --git a/spec/params_spec.rb b/spec/params_spec.rb index 6e5516b..cb0be5d 100644 --- a/spec/params_spec.rb +++ b/spec/params_spec.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rbs_inline: enabled require 'spec_helper' diff --git a/spec/permit_spec.rb b/spec/permit_spec.rb index ada7d6a..9cc314b 100644 --- a/spec/permit_spec.rb +++ b/spec/permit_spec.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rbs_inline: enabled require 'spec_helper' diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8c0222b..c6c1970 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rbs_inline: enabled require 'structured_params' require 'rspec-parameterized' diff --git a/spec/support/test_classes.rb b/spec/support/test_classes.rb index 2e09882..ff0bb3e 100644 --- a/spec/support/test_classes.rb +++ b/spec/support/test_classes.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rbs_inline: enabled require 'uri' diff --git a/spec/type/array_spec.rb b/spec/type/array_spec.rb index a31473d..17f2ea9 100644 --- a/spec/type/array_spec.rb +++ b/spec/type/array_spec.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rbs_inline: enabled require 'spec_helper' diff --git a/spec/type/object_spec.rb b/spec/type/object_spec.rb index 694546b..38dcb30 100644 --- a/spec/type/object_spec.rb +++ b/spec/type/object_spec.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rbs_inline: enabled require 'spec_helper' From 3fd3e1180a1785c829ad5c103afdd8d0947665e2 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 3 Apr 2026 05:29:59 +0900 Subject: [PATCH 10/12] Preserve error metadata in validations by using errors.import method --- lib/structured_params/validations.rb | 2 +- spec/validations_spec.rb | 33 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/structured_params/validations.rb b/lib/structured_params/validations.rb index a0435fc..7224e9f 100644 --- a/lib/structured_params/validations.rb +++ b/lib/structured_params/validations.rb @@ -63,7 +63,7 @@ def validates_raw_remap_errors self.class.validates_raw_btc_map.each do |attr, btc| next if errors.where(btc).none? - errors.where(btc).dup.each { |e| errors.add(attr, e.message) } + errors.where(btc).dup.each { |e| errors.import(e, attribute: attr) } errors.delete(btc) end end diff --git a/spec/validations_spec.rb b/spec/validations_spec.rb index de8fb37..d074166 100644 --- a/spec/validations_spec.rb +++ b/spec/validations_spec.rb @@ -71,6 +71,39 @@ end end + context 'when error metadata is preserved via errors.import' do + subject(:params) { params_class.new(age: 'abc') } + + let(:params_class) do + stub_const('ErrorMetadataParameter', Class.new(StructuredParams::Params) do + attribute :age, :integer + + validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' } + end) + end + + before { params.validate } + + it 'preserves structured error type (not a raw message string) in errors.details' do + # errors.import keeps the original type symbol (e.g. :invalid from format validator) + # rather than a raw message string like "must be numeric string" + detail_types = params.errors.details[:age].map { |d| d[:error] } + expect(detail_types).to all(be_a(Symbol)) + end + + it 'does not leak metadata onto before_type_cast attribute' do + expect(params.errors.details[:age_before_type_cast]).to be_empty + end + + it 'keeps the error findable via errors.where with its preserved type' do + # The format validator internally adds :invalid type + detail_type = params.errors.details[:age].first[:error] + error = params.errors.where(:age, detail_type).first + expect(error).not_to be_nil + expect(error.type).to eq(detail_type) + end + end + context 'when combined with validates on the same attribute' do let(:age) { 'abc' } From 70394e133e51a1be6d14db18e64995e2f46641e2 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 3 Apr 2026 05:32:11 +0900 Subject: [PATCH 11/12] Clarify validation error messages for score attribute in documentation --- docs/validation.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/validation.md b/docs/validation.md index dbc3929..8e1cdb8 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -59,10 +59,11 @@ end params = UserParams.new(score: 'abc') params.valid? # => false params.errors[:score] -# => includes both "must be numeric string" and "is not a number" +# => 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 From a18319bf91767727c85802d5179023a159a1cb73 Mon Sep 17 00:00:00 2001 From: mizuki-y Date: Fri, 3 Apr 2026 06:03:58 +0900 Subject: [PATCH 12/12] Refactor RBS documentation for raw validation methods in validations.rb and validations.rbs --- lib/structured_params/validations.rb | 1 + sig/structured_params/validations.rbs | 31 +++++++++++++++------------ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/lib/structured_params/validations.rb b/lib/structured_params/validations.rb index 7224e9f..98f76bc 100644 --- a/lib/structured_params/validations.rb +++ b/lib/structured_params/validations.rb @@ -26,6 +26,7 @@ module Validations class_attribute :validates_raw_remap_validator_installed, instance_accessor: false, default: false end + # @rbs module ClassMethods class_methods do # Validates raw attribute value before type casting. # diff --git a/sig/structured_params/validations.rbs b/sig/structured_params/validations.rbs index 755121f..192bf58 100644 --- a/sig/structured_params/validations.rbs +++ b/sig/structured_params/validations.rbs @@ -20,21 +20,24 @@ module StructuredParams module Validations extend ActiveSupport::Concern - # Validates raw attribute value before type casting. - # - # Accepts the same options as +validates+ (format, numericality, presence, etc.), - # but validates the raw input value before it is converted by ActiveModel::Attributes. - # - # Examples: - # validates_raw :age, format: { with: /\A\d+\z/ } - # validates_raw :score, numericality: { only_integer: true } - # validates_raw :code, format: { with: /\A[A-Z]+\z/, message: 'must be uppercase' } - # - # : (*Symbol, **untyped) -> void - def validates_raw: (*Symbol, **untyped) -> void + # @rbs module ClassMethods + module ClassMethods + # Validates raw attribute value before type casting. + # + # Accepts the same options as +validates+ (format, numericality, presence, etc.), + # but validates the raw input value before it is converted by ActiveModel::Attributes. + # + # Examples: + # validates_raw :age, format: { with: /\A\d+\z/ } + # validates_raw :score, numericality: { only_integer: true } + # validates_raw :code, format: { with: /\A[A-Z]+\z/, message: 'must be uppercase' } + # + # : (*Symbol, **untyped) -> void + def validates_raw: (*Symbol, **untyped) -> void - # : () -> void - def validates_raw_install_remap_validator_once: () -> void + # : () -> void + def validates_raw_install_remap_validator_once: () -> void + end private