Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .agents/skills/rspec/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .codex/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
approval_policy = "on-request"
sandbox_mode = "workspace-write"
4 changes: 4 additions & 0 deletions .rubocop_rbs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ Style/RbsInline/MissingTypeAnnotation:
Style/RbsInline/UntypedInstanceVariable:
Exclude:
- 'lib/structured_params/params.rb'

Style/RbsInline/RequireRbsInlineComment:
Exclude:
- 'spec/**/*'
80 changes: 80 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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` も更新します。
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions README_ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 コントローラーで使用
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Comment thread
Syati marked this conversation as resolved.

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:
Expand Down
2 changes: 2 additions & 0 deletions lib/structured_params.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
29 changes: 29 additions & 0 deletions lib/structured_params/attribute_methods.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions lib/structured_params/params.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -49,6 +53,8 @@ module StructuredParams
class Params
include ActiveModel::Model
include ActiveModel::Attributes
include AttributeMethods
include Validations

# @rbs @errors: ::StructuredParams::Errors?

Expand Down
72 changes: 72 additions & 0 deletions lib/structured_params/validations.rb
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions sig/structured_params/attribute_methods.rbs
Original file line number Diff line number Diff line change
@@ -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
Loading