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" 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/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` も更新します。 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..8e1cdb8 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -24,6 +24,47 @@ 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 + + # 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 + +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 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: 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/attribute_methods.rb b/lib/structured_params/attribute_methods.rb new file mode 100644 index 0000000..55cc00f --- /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 + #: (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 diff --git a/lib/structured_params/params.rb b/lib/structured_params/params.rb index 689fe95..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: @@ -49,6 +53,8 @@ module StructuredParams class Params include ActiveModel::Model include ActiveModel::Attributes + include AttributeMethods + include Validations # @rbs @errors: ::StructuredParams::Errors? diff --git a/lib/structured_params/validations.rb b/lib/structured_params/validations.rb new file mode 100644 index 0000000..98f76bc --- /dev/null +++ b/lib/structured_params/validations.rb @@ -0,0 +1,72 @@ +# 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 + + # @rbs module ClassMethods + 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.import(e, attribute: attr) } + errors.delete(btc) + end + end + end +end 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/sig/structured_params/params.rbs b/sig/structured_params/params.rbs index 1af4b63..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: @@ -50,6 +54,10 @@ module StructuredParams include ActiveModel::Attributes + include AttributeMethods + + include Validations + @errors: ::StructuredParams::Errors? self.@structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]? diff --git a/sig/structured_params/validations.rbs b/sig/structured_params/validations.rbs new file mode 100644 index 0000000..192bf58 --- /dev/null +++ b/sig/structured_params/validations.rbs @@ -0,0 +1,47 @@ +# 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 + + # @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 + end + + private + + # : () -> void + def validates_raw_remap_errors: () -> void + end +end diff --git a/spec/attribute_methods_spec.rb b/spec/attribute_methods_spec.rb new file mode 100644 index 0000000..7e2f06a --- /dev/null +++ b/spec/attribute_methods_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +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 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 fd06cc3..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' @@ -309,6 +308,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/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 83432ae..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' @@ -57,4 +56,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 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' diff --git a/spec/validations_spec.rb b/spec/validations_spec.rb new file mode 100644 index 0000000..d074166 --- /dev/null +++ b/spec/validations_spec.rb @@ -0,0 +1,149 @@ +# 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 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' } + + 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