structured_params 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7df8a9794d83f7ff21ab449fcc1c8d294ef6fcc2f07efa3f06312847efd98351
4
- data.tar.gz: 06b6b58ad9d4b5969ae944a4d84b49cb7eb163c8096ced05d345384b418ad651
3
+ metadata.gz: b571c05cd70b8d5f5d3cbf40de3d06158b8b1ddfa9d0a7bd1edb4a158cd05813
4
+ data.tar.gz: 4eeb5375b4e0d1a80feb1c0185084858e2932591c61cef9fef5ee10a1acbad42
5
5
  SHA512:
6
- metadata.gz: 9c67223de7d299fc05e4edb7d3f07a662ab08517f1fd515ecd20b5fed2bcbd9c1415a91ec64ec96ca71d79e584eb456b56e3a16ca1e5877f75c837f0be4817a3
7
- data.tar.gz: a219e6a26311d776b6b68c3dee2a0ea40bc7faf4cbb00cf55fd9f390e3594bb862514aff5fc833477540bac3acf3f6173e08c97026379369b8bd6fd6019bcfcf
6
+ metadata.gz: fe8a1dee53c763112c935211fd941d746871cf28ef2a16499cabc71625e9a8219279fb354887d9d7f8bc80d13537cd123eb19a29feb6424351ffa720207b36b0
7
+ data.tar.gz: b03d0e6cd37482e1ddc578788e3d46e191a73a53e5e091a34ee80ccf88bd1258aa2f29f3b5544fa9252309042f6060c9f630ab820087f29b29d9a7d786dca1fa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.3] - 2026-04-09
4
+
5
+ ## What's Changed
6
+ * Enhance i18n support for nested attributes and update documentation by @Syati in https://github.com/Syati/structured_params/pull/15
7
+
8
+
9
+ **Full Changelog**: https://github.com/Syati/structured_params/compare/v0.9.2...v0.9.3
10
+
11
+ ## [0.9.2] - 2026-04-02
12
+
13
+ ## What's Changed
14
+ * Clarify gemspec summary to describe type-safe parameter validation by @Syati in https://github.com/Syati/structured_params/pull/13
15
+ * Addressing PR comments by @Syati in https://github.com/Syati/structured_params/pull/14
16
+
17
+
18
+ **Full Changelog**: https://github.com/Syati/structured_params/compare/v0.9.1...v0.9.2
19
+
3
20
  ## [0.9.1] - 2026-03-18
4
21
 
5
22
  **Full Changelog**: https://github.com/Syati/structured_params/compare/v0.9.0...v0.9.1
@@ -0,0 +1,29 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module StructuredParams
5
+ # Extends ActiveModel::Attributes to define +attr_before_type_cast+ accessors
6
+ # for each attribute, mirroring ActiveRecord::AttributeMethods::BeforeTypeCast.
7
+ #
8
+ # Example:
9
+ # class UserParams < StructuredParams::Params
10
+ # attribute :age, :integer
11
+ # end
12
+ #
13
+ # params = UserParams.new(age: "42abc")
14
+ # params.age # => 42 (type-cast)
15
+ # params.age_before_type_cast # => "42abc" (raw input)
16
+ module AttributeMethods
17
+ extend ActiveSupport::Concern
18
+
19
+ included do
20
+ # Override attribute to also define `attr_before_type_cast`
21
+ # via ActiveModel::Attribute#value_before_type_cast
22
+ #: (Symbol name, *untyped) -> void
23
+ def self.attribute(name, ...)
24
+ super
25
+ define_method(:"#{name}_before_type_cast") { @attributes[name.to_s].value_before_type_cast }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,132 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module StructuredParams
5
+ # Provides i18n-aware human_attribute_name resolution for nested dot-notation
6
+ # attributes (e.g. "hobbies.0.name").
7
+ #
8
+ # When included in a Params subclass, overrides +human_attribute_name+ so that
9
+ # each segment of the path is resolved by the corresponding nested model class,
10
+ # ensuring that child-model translations are respected instead of falling back
11
+ # to the parent model's i18n context.
12
+ #
13
+ # == i18n keys
14
+ #
15
+ # You can customize how array indices and object nesting are rendered by
16
+ # defining the following keys in your locale file:
17
+ #
18
+ # ja:
19
+ # activemodel:
20
+ # errors:
21
+ # nested_attribute:
22
+ # array: "%{parent} %{index} 番目の%{child}"
23
+ # object: "%{parent}の%{child}"
24
+ #
25
+ # Without these keys the defaults are:
26
+ # array → "<parent> <index> <child>" (e.g. "Hobbies 0 Name")
27
+ # object → "<parent> <child>" (e.g. "Address Postal code")
28
+ module I18n
29
+ extend ActiveSupport::Concern
30
+
31
+ class_methods do # rubocop:disable Metrics/BlockLength
32
+ # Override human_attribute_name to resolve nested dot-notation paths.
33
+ #
34
+ # Flat attributes (no dot) are delegated to the default ActiveModel
35
+ # behaviour unchanged.
36
+ #
37
+ # Example (en default):
38
+ # human_attribute_name(:'hobbies.0.name') # => "Hobbies 0 Name"
39
+ #
40
+ # Example with i18n (ja):
41
+ # human_attribute_name(:'hobbies.0.name') # => "趣味 0 番目の名前"
42
+ #
43
+ #: (Symbol | String, ?Hash[untyped, untyped]) -> String
44
+ def human_attribute_name(attribute, options = {})
45
+ parts = attribute.to_s.split('.')
46
+ return super if parts.length == 1
47
+ return super unless structured_attributes.key?(parts.first)
48
+
49
+ resolve_nested_human_attribute_name(parts, options)
50
+ end
51
+
52
+ private
53
+
54
+ # Walk +parts+ (e.g. ["hobbies", "0", "name"]) and build a human-readable
55
+ # label by delegating each segment to the appropriate nested class.
56
+ #
57
+ # Only +:locale+ is forwarded to inner +human_attribute_name+ calls.
58
+ # Options such as +:default+ are specific to the outer call (e.g. from
59
+ # +full_messages+) and must not bleed into individual segment lookups,
60
+ # where they would replace the segment's own translation fallback.
61
+ #
62
+ #: (Array[String], Hash[untyped, untyped]) -> String
63
+ def resolve_nested_human_attribute_name(parts, options)
64
+ label = nil
65
+ klass = self
66
+ inner_opts = options.slice(:locale)
67
+
68
+ attr_segments(parts).each do |index, attr|
69
+ human = klass&.human_attribute_name(attr, inner_opts) || attr.humanize
70
+ label = build_nested_label(label, index, human, options)
71
+ klass &&= klass.structured_attributes[attr]
72
+ end
73
+
74
+ label || parts.last.humanize
75
+ end
76
+
77
+ # Convert a parts array into (index_or_nil, attr) pairs.
78
+ #
79
+ # attr_segments(["hobbies", "0", "name"]) #=> [[nil, "hobbies"], ["0", "name"]]
80
+ # attr_segments(["address", "postal_code"]) #=> [[nil, "address"], [nil, "postal_code"]]
81
+ #
82
+ #: (Array[String]) -> Array[[String?, String]]
83
+ def attr_segments(parts)
84
+ index = nil
85
+ parts.each_with_object([]) do |part, segments|
86
+ if part.match?(/\A\d+\z/)
87
+ index = part
88
+ else
89
+ segments << [index, part]
90
+ index = nil
91
+ end
92
+ end
93
+ end
94
+
95
+ # Combine +result+ (accumulated label so far), an optional array +index+,
96
+ # and the new +attr_human+ into a single label string.
97
+ #
98
+ # Uses the i18n keys:
99
+ # activemodel.errors.nested_attribute.array (parent, index, child)
100
+ # activemodel.errors.nested_attribute.object (parent, child)
101
+ #
102
+ # The +locale:+ key from +options+ is forwarded to ::I18n.t so that an
103
+ # explicit locale passed to human_attribute_name is honoured.
104
+ #
105
+ #: (String?, String?, String, Hash[untyped, untyped]) -> String
106
+ def build_nested_label(result, index, attr_human, options)
107
+ return attr_human if result.nil?
108
+
109
+ i18n_opts = options.slice(:locale)
110
+
111
+ if index
112
+ ::I18n.t(
113
+ 'activemodel.errors.nested_attribute.array',
114
+ parent: result,
115
+ index: index,
116
+ child: attr_human,
117
+ default: "#{result} #{index} #{attr_human}",
118
+ **i18n_opts
119
+ )
120
+ else
121
+ ::I18n.t(
122
+ 'activemodel.errors.nested_attribute.object',
123
+ parent: result,
124
+ child: attr_human,
125
+ default: "#{result} #{attr_human}",
126
+ **i18n_opts
127
+ )
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -11,9 +11,13 @@ module StructuredParams
11
11
  # Strong Parameters example (API):
12
12
  # class UserParams < StructuredParams::Params
13
13
  # attribute :name, :string
14
+ # attribute :age, :integer
14
15
  # attribute :address, :object, value_class: AddressParams
15
16
  # attribute :hobbies, :array, value_class: HobbyParams
16
17
  # attribute :tags, :array, value_type: :string
18
+ #
19
+ # # Validate raw input before type casting (e.g., "12x" for integer fields)
20
+ # validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' }
17
21
  # end
18
22
  #
19
23
  # # In controller:
@@ -49,6 +53,9 @@ module StructuredParams
49
53
  class Params
50
54
  include ActiveModel::Model
51
55
  include ActiveModel::Attributes
56
+ include AttributeMethods
57
+ include Validations
58
+ include I18n
52
59
 
53
60
  # @rbs @errors: ::StructuredParams::Errors?
54
61
 
@@ -284,9 +291,11 @@ module StructuredParams
284
291
  #: (untyped, String) -> void
285
292
  def import_structured_errors(structured_errors, prefix)
286
293
  structured_errors.each do |error|
287
- # Create dotted attribute path and import normally
294
+ # Create dotted attribute path and import with the message already resolved
295
+ # in the child model's i18n context, so the parent model's locale does not
296
+ # override the child's translations.
288
297
  error_attribute = "#{prefix}.#{error.attribute}"
289
- errors.import(error, attribute: error_attribute.to_sym)
298
+ errors.import(error, attribute: error_attribute.to_sym, message: error.message)
290
299
  end
291
300
  end
292
301
  end
@@ -0,0 +1,72 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module StructuredParams
5
+ # Provides +validates_raw+ which validates raw parameter values before type casting.
6
+ #
7
+ # Internally delegates to ActiveModel's +validates+ on the +_before_type_cast+
8
+ # attribute, then remaps errors back to the original attribute name.
9
+ # This means all standard ActiveModel validators (format, numericality, etc.)
10
+ # work as-is on the raw input value.
11
+ #
12
+ # Example:
13
+ # class UserParams < StructuredParams::Params
14
+ # attribute :age, :integer
15
+ # validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' }
16
+ # end
17
+ #
18
+ # params = UserParams.new(age: "abc")
19
+ # params.valid? # => false
20
+ # params.errors[:age] # => ["must be numeric string"]
21
+ module Validations
22
+ extend ActiveSupport::Concern
23
+
24
+ included do
25
+ class_attribute :validates_raw_btc_map, instance_accessor: false, default: {}
26
+ class_attribute :validates_raw_remap_validator_installed, instance_accessor: false, default: false
27
+ end
28
+
29
+ # @rbs module ClassMethods
30
+ class_methods do
31
+ # Validates raw attribute value before type casting.
32
+ #
33
+ # Accepts the same options as +validates+ (format, numericality, presence, etc.),
34
+ # but validates the raw input value before it is converted by ActiveModel::Attributes.
35
+ #
36
+ # Examples:
37
+ # validates_raw :age, format: { with: /\A\d+\z/ }
38
+ # validates_raw :score, numericality: { only_integer: true }
39
+ # validates_raw :code, format: { with: /\A[A-Z]+\z/, message: 'must be uppercase' }
40
+ #
41
+ #: (*Symbol, **untyped) -> void
42
+ def validates_raw(*attr_names, **options)
43
+ btc_map = attr_names.to_h { |attr| [attr.to_sym, :"#{attr}_before_type_cast"] }
44
+ validates(*btc_map.values, **options)
45
+ self.validates_raw_btc_map = validates_raw_btc_map.merge(btc_map)
46
+ validates_raw_install_remap_validator_once
47
+ end
48
+
49
+ #: () -> void
50
+ def validates_raw_install_remap_validator_once
51
+ return if validates_raw_remap_validator_installed
52
+
53
+ set_callback(:validate, :after, :validates_raw_remap_errors)
54
+
55
+ self.validates_raw_remap_validator_installed = true
56
+ end
57
+ private :validates_raw_install_remap_validator_once
58
+ end
59
+
60
+ private
61
+
62
+ #: () -> void
63
+ def validates_raw_remap_errors
64
+ self.class.validates_raw_btc_map.each do |attr, btc|
65
+ next if errors.where(btc).none?
66
+
67
+ errors.where(btc).dup.each { |e| errors.import(e, attribute: attr) }
68
+ errors.delete(btc)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module StructuredParams
5
- VERSION = '0.9.1' #: string
5
+ VERSION = '0.9.3' #: string
6
6
  end
@@ -10,6 +10,9 @@ require_relative 'structured_params/version'
10
10
 
11
11
  # errors
12
12
  require_relative 'structured_params/errors'
13
+ require_relative 'structured_params/attribute_methods'
14
+ require_relative 'structured_params/validations'
15
+ require_relative 'structured_params/i18n'
13
16
 
14
17
  # types (load first for module definition)
15
18
  require_relative 'structured_params/type/object'
@@ -0,0 +1,23 @@
1
+ # Generated from lib/structured_params/attribute_methods.rb with RBS::Inline
2
+
3
+ module StructuredParams
4
+ # Extends ActiveModel::Attributes to define +attr_before_type_cast+ accessors
5
+ # for each attribute, mirroring ActiveRecord::AttributeMethods::BeforeTypeCast.
6
+ #
7
+ # Example:
8
+ # class UserParams < StructuredParams::Params
9
+ # attribute :age, :integer
10
+ # end
11
+ #
12
+ # params = UserParams.new(age: "42abc")
13
+ # params.age # => 42 (type-cast)
14
+ # params.age_before_type_cast # => "42abc" (raw input)
15
+ module AttributeMethods
16
+ extend ActiveSupport::Concern
17
+
18
+ # Override attribute to also define `attr_before_type_cast`
19
+ # via ActiveModel::Attribute#value_before_type_cast
20
+ # : (Symbol name, *untyped) -> void
21
+ def self.attribute: (Symbol name, *untyped) -> void
22
+ end
23
+ end
@@ -0,0 +1,78 @@
1
+ # Generated from lib/structured_params/i18n.rb with RBS::Inline
2
+
3
+ module StructuredParams
4
+ # Provides i18n-aware human_attribute_name resolution for nested dot-notation
5
+ # attributes (e.g. "hobbies.0.name").
6
+ #
7
+ # When included in a Params subclass, overrides +human_attribute_name+ so that
8
+ # each segment of the path is resolved by the corresponding nested model class,
9
+ # ensuring that child-model translations are respected instead of falling back
10
+ # to the parent model's i18n context.
11
+ #
12
+ # == i18n keys
13
+ #
14
+ # You can customize how array indices and object nesting are rendered by
15
+ # defining the following keys in your locale file:
16
+ #
17
+ # ja:
18
+ # activemodel:
19
+ # errors:
20
+ # nested_attribute:
21
+ # array: "%{parent} %{index} 番目の%{child}"
22
+ # object: "%{parent}の%{child}"
23
+ #
24
+ # Without these keys the defaults are:
25
+ # array → "<parent> <index> <child>" (e.g. "Hobbies 0 Name")
26
+ # object → "<parent> <child>" (e.g. "Address Postal code")
27
+ module I18n
28
+ extend ActiveSupport::Concern
29
+
30
+ # Override human_attribute_name to resolve nested dot-notation paths.
31
+ #
32
+ # Flat attributes (no dot) are delegated to the default ActiveModel
33
+ # behaviour unchanged.
34
+ #
35
+ # Example (en default):
36
+ # human_attribute_name(:'hobbies.0.name') # => "Hobbies 0 Name"
37
+ #
38
+ # Example with i18n (ja):
39
+ # human_attribute_name(:'hobbies.0.name') # => "趣味 0 番目の名前"
40
+ #
41
+ # : (Symbol | String, ?Hash[untyped, untyped]) -> String
42
+ def human_attribute_name: (Symbol | String, ?Hash[untyped, untyped]) -> String
43
+
44
+ private
45
+
46
+ # Walk +parts+ (e.g. ["hobbies", "0", "name"]) and build a human-readable
47
+ # label by delegating each segment to the appropriate nested class.
48
+ #
49
+ # Only +:locale+ is forwarded to inner +human_attribute_name+ calls.
50
+ # Options such as +:default+ are specific to the outer call (e.g. from
51
+ # +full_messages+) and must not bleed into individual segment lookups,
52
+ # where they would replace the segment's own translation fallback.
53
+ #
54
+ # : (Array[String], Hash[untyped, untyped]) -> String
55
+ def resolve_nested_human_attribute_name: (Array[String], Hash[untyped, untyped]) -> String
56
+
57
+ # Convert a parts array into (index_or_nil, attr) pairs.
58
+ #
59
+ # attr_segments(["hobbies", "0", "name"]) #=> [[nil, "hobbies"], ["0", "name"]]
60
+ # attr_segments(["address", "postal_code"]) #=> [[nil, "address"], [nil, "postal_code"]]
61
+ #
62
+ # : (Array[String]) -> Array[[String?, String]]
63
+ def attr_segments: (Array[String]) -> Array[[ String?, String ]]
64
+
65
+ # Combine +result+ (accumulated label so far), an optional array +index+,
66
+ # and the new +attr_human+ into a single label string.
67
+ #
68
+ # Uses the i18n keys:
69
+ # activemodel.errors.nested_attribute.array (parent, index, child)
70
+ # activemodel.errors.nested_attribute.object (parent, child)
71
+ #
72
+ # The +locale:+ key from +options+ is forwarded to ::I18n.t so that an
73
+ # explicit locale passed to human_attribute_name is honoured.
74
+ #
75
+ # : (String?, String?, String, Hash[untyped, untyped]) -> String
76
+ def build_nested_label: (String?, String?, String, Hash[untyped, untyped]) -> String
77
+ end
78
+ end
@@ -10,9 +10,13 @@ module StructuredParams
10
10
  # Strong Parameters example (API):
11
11
  # class UserParams < StructuredParams::Params
12
12
  # attribute :name, :string
13
+ # attribute :age, :integer
13
14
  # attribute :address, :object, value_class: AddressParams
14
15
  # attribute :hobbies, :array, value_class: HobbyParams
15
16
  # attribute :tags, :array, value_type: :string
17
+ #
18
+ # # Validate raw input before type casting (e.g., "12x" for integer fields)
19
+ # validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' }
16
20
  # end
17
21
  #
18
22
  # # In controller:
@@ -50,6 +54,12 @@ module StructuredParams
50
54
 
51
55
  include ActiveModel::Attributes
52
56
 
57
+ include AttributeMethods
58
+
59
+ include Validations
60
+
61
+ include I18n
62
+
53
63
  @errors: ::StructuredParams::Errors?
54
64
 
55
65
  self.@structured_attributes: Hash[Symbol, singleton(::StructuredParams::Params)]?
@@ -0,0 +1,47 @@
1
+ # Generated from lib/structured_params/validations.rb with RBS::Inline
2
+
3
+ module StructuredParams
4
+ # Provides +validates_raw+ which validates raw parameter values before type casting.
5
+ #
6
+ # Internally delegates to ActiveModel's +validates+ on the +_before_type_cast+
7
+ # attribute, then remaps errors back to the original attribute name.
8
+ # This means all standard ActiveModel validators (format, numericality, etc.)
9
+ # work as-is on the raw input value.
10
+ #
11
+ # Example:
12
+ # class UserParams < StructuredParams::Params
13
+ # attribute :age, :integer
14
+ # validates_raw :age, format: { with: /\A\d+\z/, message: 'must be numeric string' }
15
+ # end
16
+ #
17
+ # params = UserParams.new(age: "abc")
18
+ # params.valid? # => false
19
+ # params.errors[:age] # => ["must be numeric string"]
20
+ module Validations
21
+ extend ActiveSupport::Concern
22
+
23
+ # @rbs module ClassMethods
24
+ module ClassMethods
25
+ # Validates raw attribute value before type casting.
26
+ #
27
+ # Accepts the same options as +validates+ (format, numericality, presence, etc.),
28
+ # but validates the raw input value before it is converted by ActiveModel::Attributes.
29
+ #
30
+ # Examples:
31
+ # validates_raw :age, format: { with: /\A\d+\z/ }
32
+ # validates_raw :score, numericality: { only_integer: true }
33
+ # validates_raw :code, format: { with: /\A[A-Z]+\z/, message: 'must be uppercase' }
34
+ #
35
+ # : (*Symbol, **untyped) -> void
36
+ def validates_raw: (*Symbol, **untyped) -> void
37
+
38
+ # : () -> void
39
+ def validates_raw_install_remap_validator_once: () -> void
40
+ end
41
+
42
+ private
43
+
44
+ # : () -> void
45
+ def validates_raw_remap_errors: () -> void
46
+ end
47
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: structured_params
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mizuki Yamamoto
@@ -59,16 +59,22 @@ files:
59
59
  - CHANGELOG.md
60
60
  - LICENSE.txt
61
61
  - lib/structured_params.rb
62
+ - lib/structured_params/attribute_methods.rb
62
63
  - lib/structured_params/errors.rb
64
+ - lib/structured_params/i18n.rb
63
65
  - lib/structured_params/params.rb
64
66
  - lib/structured_params/type/array.rb
65
67
  - lib/structured_params/type/object.rb
68
+ - lib/structured_params/validations.rb
66
69
  - lib/structured_params/version.rb
67
70
  - sig/structured_params.rbs
71
+ - sig/structured_params/attribute_methods.rbs
68
72
  - sig/structured_params/errors.rbs
73
+ - sig/structured_params/i18n.rbs
69
74
  - sig/structured_params/params.rbs
70
75
  - sig/structured_params/type/array.rbs
71
76
  - sig/structured_params/type/object.rbs
77
+ - sig/structured_params/validations.rbs
72
78
  - sig/structured_params/version.rbs
73
79
  homepage: https://github.com/Syati/structured_params
74
80
  licenses:
@@ -95,5 +101,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
95
101
  requirements: []
96
102
  rubygems_version: 3.6.9
97
103
  specification_version: 4
98
- summary: StructuredParams allows you to validate pass parameter.
104
+ summary: Type-safe parameter validation and form objects for Rails.
99
105
  test_files: []