Skip to content
Open
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
111 changes: 111 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Copilot Instructions for structured_params

## Coding Style

### RBS Inline

This project uses `rbs-inline` for type annotations. Use the **method_type_signature** style:

```ruby
# Good: method_type_signature style
class Example
#: () -> String
def method_name
"result"
end

#: (String) -> Integer
def method_with_param(value)
value.length
end

#: (String value, ?Integer default) -> String
def method_with_optional(value, default: 0)
"#{value}: #{default}"
end
end
```

**Exception: Instance variables** must use doc style (`# @rbs`):

```ruby
# Good: instance variable type definition
class Example
# @rbs @name: String?

class << self
# @rbs @cache: Hash[Symbol, String]?
end

#: (String) -> void
def initialize(name)
@name = name
end
end
```

**DO NOT** use the doc style for method signatures:

```ruby
# Bad: doc style for methods (do not use)
# @rbs return: String
def method_name
"result"
end

# @rbs param: String
# @rbs return: Integer
def method_with_param(value)
value.length
end
```

### Configuration

The RuboCop configuration enforces this style:

```yaml
Style/RbsInline/MissingTypeAnnotation:
EnforcedStyle: method_type_signature
```

### RBS Signature Generation

**DO NOT** manually edit files in `sig/` directory. These files are auto-generated from inline annotations.

To generate RBS signature files:

```bash
# Run this command to generate sig files from inline annotations
lefthook run prepare-commit-msg
```

This command will:
1. Extract type annotations from Ruby files using `rbs-inline`
2. Generate corresponding `.rbs` files in `sig/` directory
3. Ensure type signatures are in sync with the code

**Note:** The `sig/` directory is automatically updated by the git hook, but you can manually run it when needed.

## Project-Specific Guidelines

### Strong Parameters

- For API usage: Use simple `UserParams.new(params)`
- For Form Objects: Use `UserForm.new(UserForm.permit(params))`
- `permit` method is available but not required for API usage

### Form Objects

This gem supports both Strong Parameters validation and Form Object pattern:

- Form Objects should use `permit(params)` to handle `require` + `permit`
- `model_name` automatically removes "Parameters", "Parameter", or "Form" suffix
- Provides `persisted?`, `to_key`, `to_model` for Rails form helpers integration

### Testing

- Use RSpec for testing
- Group tests by context (e.g., "API context", "Form Object context")
- Test files are in `spec/` directory
- Support files (test helper classes) are in `spec/support/`
102 changes: 97 additions & 5 deletions lib/structured_params/params.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,37 @@
module StructuredParams
# Parameter model that supports structured objects and arrays
#
# Usage example:
# class UserParameter < StructuredParams::Params
# This class can be used in two ways:
# 1. Strong Parameters validation (params validation in controllers)
# 2. Form objects (using with form_with/form_for in views)
#
# Strong Parameters example:
# class UserParameters < StructuredParams::Params
# attribute :name, :string
# attribute :address, :object, value_class: AddressParameter
# attribute :hobbies, :array, value_class: HobbyParameter
# attribute :address, :object, value_class: AddressParameters
# attribute :hobbies, :array, value_class: HobbyParameters
# attribute :tags, :array, value_type: :string
# end
#
# Form object example:
# class UserRegistrationForm < StructuredParams::Params
# attribute :name, :string
# attribute :email, :string
# validates :name, presence: true
# validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
# end
#
# # In controller:
# @form = UserRegistrationForm.new(params[:user])
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The controller usage example in the class-level documentation comment shows @form = UserRegistrationForm.new(params[:user]), but for UserRegistrationForm the param_key is user_registration (the "Form" suffix is stripped, leaving UserRegistration). This means params[:user] would be nil and the form would receive no data. The example should use params[:user_registration], or better yet, use the permit method as documented in the copilot instructions (e.g., UserRegistrationForm.new(UserRegistrationForm.permit(params))).

This is misleading documentation that could cause developers using the form object pattern to construct the object with nil or empty params.

Suggested change
# @form = UserRegistrationForm.new(params[:user])
# @form = UserRegistrationForm.new(UserRegistrationForm.permit(params))

Copilot uses AI. Check for mistakes.
# if @form.valid?
# # Save to database
# end
#
# # In view:
# <%= form_with model: @form, url: users_path do |f| %>
# <%= f.text_field :name %>
# <%= f.text_field :email %>
# <% end %>
class Params
include ActiveModel::Model
include ActiveModel::Attributes
Expand All @@ -19,6 +43,26 @@ class Params

class << self
# @rbs @structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]?
# @rbs @model_name: ::ActiveModel::Name?

# Override model_name for form helpers
# By default, removes "Parameters", "Parameter", or "Form" suffix from class name
# This allows the class to work seamlessly with Rails form helpers
#
# Example:
# UserRegistrationForm.model_name.name # => "UserRegistration"
# UserRegistrationForm.model_name.param_key # => "user_registration"
# UserParameters.model_name.name # => "User"
# Admin::UserForm.model_name.name # => "Admin::User"
#: () -> ::ActiveModel::Name
def model_name
@model_name ||= begin
namespace = module_parents.detect { |n| n.respond_to?(:use_relative_model_naming?) }
# Remove suffix from the full class name (preserving namespace)
name_without_suffix = name.sub(/(Parameters?|Form)$/, '')
ActiveModel::Name.new(self, namespace, name_without_suffix)
end
end

# Generate permitted parameter structure for Strong Parameters
#: () -> Array[untyped]
Expand All @@ -34,6 +78,28 @@ def permit_attribute_names
end
end

# Permit parameters with optional require
#
# For Form Objects (with require):
# UserRegistrationForm.permit(params)
# # equivalent to:
# params.require(:user_registration).permit(UserRegistrationForm.permit_attribute_names)
#
# For API requests (without require):
# UserParams.permit(params, require: false)
# # equivalent to:
# params.permit(UserParams.permit_attribute_names)
#
#: (ActionController::Parameters params, ?require: bool) -> ActionController::Parameters
def permit(params, require: true)
if require
key = model_name.param_key.to_sym
params.require(key).permit(permit_attribute_names)
else
params.permit(permit_attribute_names)
end
end

# Get structured attributes and their classes
#: () -> Hash[Symbol, singleton(::StructuredParams::Params)]
def structured_attributes
Expand Down Expand Up @@ -72,6 +138,32 @@ def errors
@errors ||= Errors.new(self)
end

# ========================================
# Form object support methods
# These methods enable integration with Rails form helpers (form_with, form_for)
# ========================================

# Indicates whether the form object has been persisted to database
# Always returns false for parameter/form objects
#: () -> bool
def persisted?
false
end

# Returns the primary key value for the model
# Always returns nil for parameter/form objects
#: () -> nil
def to_key
nil
end

# Returns self for form helpers
# Required by Rails form helpers to get the model object
#: () -> self
def to_model
self
end

# Convert structured objects to Hash and get attributes
#: (?symbolize: false, ?compact_mode: :none | :nil_only | :all_blank) -> Hash[String, untyped]
#: (?symbolize: true, ?compact_mode: :none | :nil_only | :all_blank) -> Hash[Symbol, untyped]
Expand Down Expand Up @@ -102,7 +194,7 @@ def attributes(symbolize: false, compact_mode: :none)
def process_input_parameters(params)
case params
when ActionController::Parameters
params.permit(self.class.permit_attribute_names).to_h
self.class.permit(params, require: false).to_h
when Hash
# ActiveModel::Attributes can handle both symbol and string keys
params
Expand Down
69 changes: 65 additions & 4 deletions sig/structured_params/params.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,25 @@
module StructuredParams
# Parameter model that supports structured objects and arrays
#
# Usage example:
# class UserParameter < StructuredParams::Params
# This class can be used in two ways:
# 1. Strong Parameters validation (params validation in controllers)
# 2. Form objects (using with form_with/form_for in views)
#
# Strong Parameters example:
# class UserParameters < StructuredParams::Params
# attribute :name, :string
# attribute :address, :object, value_class: AddressParameter
# attribute :hobbies, :array, value_class: HobbyParameter
# attribute :address, :object, value_class: AddressParameters
# attribute :hobbies, :array, value_class: HobbyParameters
# attribute :tags, :array, value_type: :string
# end
#
# Form object example:
# class UserRegistrationForm < StructuredParams::Params
# attribute :name, :string
# attribute :email, :string
# validates :name, presence: true
# validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
# end
class Params
include ActiveModel::Model

Expand All @@ -19,10 +31,39 @@ module StructuredParams

self.@structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]?

self.@_model_name: ::ActiveModel::Name?
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RBS type annotation for the model name memoization variable is inconsistent with the actual Ruby source code. In lib/structured_params/params.rb (line 46), the inline annotation declares # @rbs @model_name: ::ActiveModel::Name?, and the model_name method (line 59) stores the result in @model_name. However, the generated sig/structured_params/params.rbs declares self.@_model_name: ::ActiveModel::Name? (with a leading underscore).

This mismatch means Steep type-checking will track @_model_name but the actual code uses @model_name, which can cause type errors or missed type coverage. Since the .github/copilot-instructions.md explicitly states "DO NOT manually edit files in sig/ directory" (the file is auto-generated), the .rbs file should reflect the inline annotation exactly. The inline annotation in params.rb should be corrected to # @rbs @_model_name: ::ActiveModel::Name? to match the generated output, or the .rbs needs to be regenerated after fixing the annotation.

Suggested change
self.@_model_name: ::ActiveModel::Name?
self.@model_name: ::ActiveModel::Name?

Copilot uses AI. Check for mistakes.

# Override model_name for form helpers
# By default, removes "Parameters", "Parameter", or "Form" suffix from class name
# This allows the class to work seamlessly with Rails form helpers
#
# Example:
# UserRegistrationForm.model_name.name # => "UserRegistration"
# UserRegistrationForm.model_name.param_key # => "user_registration"
# UserParameters.model_name.name # => "User"
# Admin::UserForm.model_name.name # => "Admin::User"
# : () -> ::ActiveModel::Name
def self.model_name: () -> ::ActiveModel::Name

# Generate permitted parameter structure for Strong Parameters
# : () -> Array[untyped]
def self.permit_attribute_names: () -> Array[untyped]

# Permit parameters with optional require
#
# For Form Objects (with require):
# UserRegistrationForm.permit(params)
# # equivalent to:
# params.require(:user_registration).permit(UserRegistrationForm.permit_attribute_names)
#
# For API requests (without require):
# UserParams.permit(params, require: false)
# # equivalent to:
# params.permit(UserParams.permit_attribute_names)
#
# : (ActionController::Parameters params, ?require: bool) -> ActionController::Parameters
def self.permit: (ActionController::Parameters params, ?require: bool) -> ActionController::Parameters

# Get structured attributes and their classes
# : () -> Hash[Symbol, singleton(::StructuredParams::Params)]
def self.structured_attributes: () -> Hash[Symbol, singleton(::StructuredParams::Params)]
Expand All @@ -37,6 +78,26 @@ module StructuredParams
# : () -> ::StructuredParams::Errors
def errors: () -> ::StructuredParams::Errors

# ========================================
# Form object support methods
# These methods enable integration with Rails form helpers (form_with, form_for)
# ========================================

# Indicates whether the form object has been persisted to database
# Always returns false for parameter/form objects
# : () -> bool
def persisted?: () -> bool

# Returns the primary key value for the model
# Always returns nil for parameter/form objects
# : () -> nil
def to_key: () -> nil

# Returns self for form helpers
# Required by Rails form helpers to get the model object
# : () -> self
def to_model: () -> self

# Convert structured objects to Hash and get attributes
# : (?symbolize: false, ?compact_mode: :none | :nil_only | :all_blank) -> Hash[String, untyped]
# : (?symbolize: true, ?compact_mode: :none | :nil_only | :all_blank) -> Hash[Symbol, untyped]
Expand Down
Loading