cattri 0.1.3 → 0.2.0
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 +4 -4
- data/.github/workflows/main.yml +34 -0
- data/.gitignore +72 -0
- data/.rubocop.yml +6 -3
- data/CHANGELOG.md +41 -0
- data/Gemfile +12 -0
- data/README.md +163 -151
- data/Steepfile +6 -0
- data/bin/console +33 -0
- data/bin/setup +8 -0
- data/cattri.gemspec +5 -5
- data/lib/cattri/attribute.rb +119 -155
- data/lib/cattri/attribute_compiler.rb +104 -0
- data/lib/cattri/attribute_options.rb +183 -0
- data/lib/cattri/attribute_registry.rb +155 -0
- data/lib/cattri/context.rb +124 -106
- data/lib/cattri/context_registry.rb +36 -0
- data/lib/cattri/deferred_attributes.rb +73 -0
- data/lib/cattri/dsl.rb +54 -0
- data/lib/cattri/error.rb +17 -90
- data/lib/cattri/inheritance.rb +35 -0
- data/lib/cattri/initializer_patch.rb +37 -0
- data/lib/cattri/internal_store.rb +104 -0
- data/lib/cattri/introspection.rb +56 -49
- data/lib/cattri/version.rb +3 -1
- data/lib/cattri.rb +38 -99
- data/sig/lib/cattri/attribute.rbs +105 -0
- data/sig/lib/cattri/attribute_compiler.rbs +61 -0
- data/sig/lib/cattri/attribute_options.rbs +150 -0
- data/sig/lib/cattri/attribute_registry.rbs +95 -0
- data/sig/lib/cattri/context.rbs +130 -0
- data/sig/lib/cattri/context_registry.rbs +31 -0
- data/sig/lib/cattri/deferred_attributes.rbs +53 -0
- data/sig/lib/cattri/dsl.rbs +55 -0
- data/sig/lib/cattri/error.rbs +28 -0
- data/sig/lib/cattri/inheritance.rbs +21 -0
- data/sig/lib/cattri/initializer_patch.rbs +26 -0
- data/sig/lib/cattri/internal_store.rbs +75 -0
- data/sig/lib/cattri/introspection.rbs +61 -0
- data/sig/lib/cattri/types.rbs +19 -0
- data/sig/lib/cattri/visibility.rbs +55 -0
- data/sig/lib/cattri.rbs +37 -0
- data/spec/cattri/attribute_compiler_spec.rb +179 -0
- data/spec/cattri/attribute_options_spec.rb +267 -0
- data/spec/cattri/attribute_registry_spec.rb +257 -0
- data/spec/cattri/attribute_spec.rb +297 -0
- data/spec/cattri/context_registry_spec.rb +45 -0
- data/spec/cattri/context_spec.rb +346 -0
- data/spec/cattri/deferred_attrributes_spec.rb +117 -0
- data/spec/cattri/dsl_spec.rb +69 -0
- data/spec/cattri/error_spec.rb +37 -0
- data/spec/cattri/inheritance_spec.rb +60 -0
- data/spec/cattri/initializer_patch_spec.rb +35 -0
- data/spec/cattri/internal_store_spec.rb +139 -0
- data/spec/cattri/introspection_spec.rb +90 -0
- data/spec/cattri/visibility_spec.rb +68 -0
- data/spec/cattri_spec.rb +54 -0
- data/spec/simplecov_helper.rb +21 -0
- data/spec/spec_helper.rb +16 -0
- metadata +79 -6
- data/lib/cattri/attribute_definer.rb +0 -143
- data/lib/cattri/class_attributes.rb +0 -277
- data/lib/cattri/instance_attributes.rb +0 -276
- data/sig/cattri.rbs +0 -4
data/cattri.gemspec
CHANGED
@@ -16,25 +16,25 @@ Gem::Specification.new do |spec|
|
|
16
16
|
spec.required_ruby_version = ">= 2.7.0"
|
17
17
|
|
18
18
|
spec.metadata["homepage_uri"] = spec.homepage
|
19
|
-
spec.metadata["source_code_uri"] =
|
19
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
20
20
|
spec.metadata["changelog_uri"] = "https://github.com/bnlucas/cattri/blob/main/CHANGELOG.md"
|
21
|
+
spec.metadata["documentation_uri"] = "https://www.rubydoc.info/gems/cattri"
|
21
22
|
spec.metadata["rubygems_mfa_required"] = "true"
|
22
23
|
|
23
24
|
spec.files = Dir.chdir(__dir__) do
|
24
|
-
`git ls-files -z`.split("\x0").
|
25
|
-
(File.expand_path(f) == __FILE__) ||
|
26
|
-
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
27
|
-
end
|
25
|
+
(`git ls-files -z`.split("\x0") + %w[README.md LICENSE.txt]).uniq
|
28
26
|
end
|
29
27
|
|
30
28
|
# Runtime dependencies
|
31
29
|
# spec.add_dependency "gem"
|
32
30
|
|
33
31
|
# Development dependencies
|
32
|
+
spec.add_development_dependency "debride"
|
34
33
|
spec.add_development_dependency "rspec"
|
35
34
|
spec.add_development_dependency "rubocop"
|
36
35
|
spec.add_development_dependency "simplecov"
|
37
36
|
spec.add_development_dependency "simplecov-cobertura"
|
38
37
|
spec.add_development_dependency "simplecov-html"
|
38
|
+
spec.add_development_dependency "steep"
|
39
39
|
spec.add_development_dependency "yard"
|
40
40
|
end
|
data/lib/cattri/attribute.rb
CHANGED
@@ -1,204 +1,168 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative "
|
3
|
+
require_relative "attribute_options"
|
4
4
|
|
5
5
|
module Cattri
|
6
|
-
#
|
6
|
+
# @internal
|
7
7
|
#
|
8
|
-
#
|
9
|
-
#
|
8
|
+
# Attribute acts as a thin wrapper around AttributeOptions,
|
9
|
+
# exposing core attribute metadata and behavior in a safe, immutable way.
|
10
10
|
#
|
11
|
-
#
|
12
|
-
#
|
11
|
+
# Each Attribute instance represents a single logical property,
|
12
|
+
# and delegates its behavior (default, visibility, coercion, etc.) to its associated AttributeOptions.
|
13
|
+
#
|
14
|
+
# @example
|
15
|
+
# attribute = Attribute.new(:enabled, default: true, expose: :read_write)
|
16
|
+
# attribute.name # => :enabled
|
17
|
+
# attribute.default.call # => true
|
18
|
+
# attribute.expose # => :read_write
|
13
19
|
class Attribute
|
14
|
-
#
|
15
|
-
|
16
|
-
|
17
|
-
# Supported Ruby method visibility levels.
|
18
|
-
ACCESS_LEVELS = %i[public protected private].freeze
|
20
|
+
# @return [Module] the class or module this attribute was defined in
|
21
|
+
attr_reader :defined_in
|
19
22
|
|
20
|
-
#
|
21
|
-
|
23
|
+
# Initializes a new attribute definition.
|
24
|
+
#
|
25
|
+
# @param name [Symbol, String] the attribute name
|
26
|
+
# @param defined_in [Module] the class or module where this attribute is defined
|
27
|
+
# @param options [Hash] configuration options
|
28
|
+
# @option options [Boolean] :scope whether the attribute is class-level (internally mapped to :class_attribute)
|
29
|
+
# @param transformer [Proc] optional block used to coerce/validate assigned values
|
30
|
+
def initialize(name, defined_in:, **options, &transformer)
|
31
|
+
@options = Cattri::AttributeOptions.new(name, transformer: transformer, **options)
|
32
|
+
@defined_in = defined_in
|
33
|
+
end
|
22
34
|
|
23
|
-
#
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
35
|
+
# Serializes this attribute and its configuration to a frozen hash.
|
36
|
+
#
|
37
|
+
# @return [Hash<Symbol, Object>]
|
38
|
+
def to_h
|
39
|
+
{
|
40
|
+
name: @options.name,
|
41
|
+
ivar: @options.ivar,
|
42
|
+
defined_in: @defined_in,
|
43
|
+
final: @options.final,
|
44
|
+
scope: @options.scope,
|
45
|
+
predicate: @options.predicate,
|
46
|
+
default: @options.default,
|
47
|
+
transformer: @options.transformer,
|
48
|
+
expose: @options.expose,
|
49
|
+
visibility: @options.visibility
|
50
|
+
}
|
51
|
+
end
|
29
52
|
|
30
|
-
#
|
31
|
-
|
32
|
-
reader: true,
|
33
|
-
writer: true,
|
34
|
-
predicate: false
|
35
|
-
}.freeze
|
53
|
+
# @!attribute [r] name
|
54
|
+
# @return [Symbol] the canonical name of the attribute
|
36
55
|
|
37
|
-
#
|
38
|
-
|
56
|
+
# @!attribute [r] ivar
|
57
|
+
# @return [Symbol] the backing instance variable (e.g., :@enabled)
|
39
58
|
|
40
|
-
#
|
41
|
-
|
59
|
+
# @!attribute [r] default
|
60
|
+
# @return [Proc] a callable lambda for the attribute’s default value
|
42
61
|
|
43
|
-
#
|
44
|
-
|
62
|
+
# @!attribute [r] transformer
|
63
|
+
# @return [Proc] a callable transformer used to process assigned values
|
45
64
|
|
46
|
-
#
|
47
|
-
|
65
|
+
# @!attribute [r] expose
|
66
|
+
# @return [Symbol] method exposure type (:read, :write, :read_write, or :none)
|
48
67
|
|
49
|
-
#
|
50
|
-
|
68
|
+
# @!attribute [r] visibility
|
69
|
+
# @return [Symbol] method visibility (:public, :protected, :private)
|
51
70
|
|
52
|
-
|
53
|
-
|
71
|
+
%i[
|
72
|
+
name
|
73
|
+
ivar
|
74
|
+
default
|
75
|
+
transformer
|
76
|
+
expose
|
77
|
+
visibility
|
78
|
+
].each do |option|
|
79
|
+
define_method(option) { @options.public_send(option) }
|
80
|
+
end
|
54
81
|
|
55
|
-
#
|
56
|
-
|
57
|
-
|
58
|
-
# @param type [Symbol] either :class or :instance
|
59
|
-
# @param options [Hash] additional attribute configuration
|
60
|
-
# @param block [Proc, nil] optional block for setter coercion
|
61
|
-
#
|
62
|
-
# @raise [Cattri::UnsupportedTypeError] if an invalid type is provided
|
63
|
-
def initialize(name, type, options, block)
|
64
|
-
@type = type.to_sym
|
65
|
-
raise Cattri::UnsupportedTypeError, type unless ATTRIBUTE_TYPES.include?(@type)
|
66
|
-
|
67
|
-
@name = name.to_sym
|
68
|
-
@ivar = normalize_ivar(options[:ivar])
|
69
|
-
@access = options[:access] || :public
|
70
|
-
@default = normalize_default(options[:default])
|
71
|
-
@setter = normalize_setter(block)
|
72
|
-
@options = typed_options(options)
|
82
|
+
# @return [Boolean] whether the reader should remain internal
|
83
|
+
def internal_reader?
|
84
|
+
%i[write none].include?(@options.expose)
|
73
85
|
end
|
74
86
|
|
75
|
-
#
|
76
|
-
|
77
|
-
|
78
|
-
# @return [Object]
|
79
|
-
def [](key)
|
80
|
-
to_hash[key.to_sym]
|
87
|
+
# @return [Boolean] whether the writer should remain internal
|
88
|
+
def internal_writer?
|
89
|
+
%i[read none].include?(@options.expose)
|
81
90
|
end
|
82
91
|
|
83
|
-
#
|
84
|
-
|
85
|
-
|
86
|
-
def to_hash
|
87
|
-
@to_hash ||= {
|
88
|
-
name: @name,
|
89
|
-
ivar: @ivar,
|
90
|
-
type: @type,
|
91
|
-
access: @access,
|
92
|
-
default: @default,
|
93
|
-
setter: @setter
|
94
|
-
}.merge(@options)
|
92
|
+
# @return [Boolean] whether the attribute allows reading
|
93
|
+
def readable?
|
94
|
+
%i[read read_write].include?(@options.expose)
|
95
95
|
end
|
96
96
|
|
97
|
-
|
97
|
+
# @return [Boolean] whether the attribute allows writing
|
98
|
+
def writable?
|
99
|
+
return false if @options.expose == :none
|
98
100
|
|
99
|
-
|
100
|
-
def class_level?
|
101
|
-
type == :class
|
101
|
+
!readonly?
|
102
102
|
end
|
103
103
|
|
104
|
-
# @return [Boolean]
|
105
|
-
def
|
106
|
-
|
107
|
-
end
|
104
|
+
# @return [Boolean] whether the attribute is marked readonly
|
105
|
+
def readonly?
|
106
|
+
return false if @options.expose == :none
|
108
107
|
|
109
|
-
|
110
|
-
def public?
|
111
|
-
access == :public
|
108
|
+
@options.expose == :read || final?
|
112
109
|
end
|
113
110
|
|
114
|
-
# @return [Boolean] whether the attribute is
|
115
|
-
def
|
116
|
-
|
111
|
+
# @return [Boolean] whether the attribute is marked final (write-once)
|
112
|
+
def final?
|
113
|
+
@options.final
|
117
114
|
end
|
118
115
|
|
119
|
-
# @return [Boolean] whether the attribute is
|
120
|
-
def
|
121
|
-
|
116
|
+
# @return [Boolean] whether the attribute is class-level
|
117
|
+
def class_attribute?
|
118
|
+
@options.scope == :class
|
122
119
|
end
|
123
120
|
|
124
|
-
#
|
125
|
-
|
126
|
-
|
127
|
-
# @raise [Cattri::AttributeError] if the default value logic raises an error
|
128
|
-
def invoke_default
|
129
|
-
default.call
|
130
|
-
rescue StandardError => e
|
131
|
-
raise Cattri::AttributeError, "Failed to evaluate the default value for :#{name}. Error: #{e.message}"
|
121
|
+
# @return [Boolean] whether the attribute defines a predicate method (`:name?`)
|
122
|
+
def with_predicate?
|
123
|
+
@options.predicate
|
132
124
|
end
|
133
125
|
|
134
|
-
#
|
126
|
+
# Returns the methods that will be defined for this attribute.
|
135
127
|
#
|
136
|
-
#
|
137
|
-
# @param kwargs [Hash] the keyword arguments
|
138
|
-
# @raise [Cattri::AttributeError] if setter raises an error
|
139
|
-
# @return [Object] the value returned by the setter
|
140
|
-
def invoke_setter(*args, **kwargs)
|
141
|
-
setter.call(*args, **kwargs)
|
142
|
-
rescue StandardError => e
|
143
|
-
raise Cattri::AttributeError, "Failed to evaluate the setter for :#{name}. Error: #{e.message}"
|
144
|
-
end
|
145
|
-
|
146
|
-
private
|
147
|
-
|
148
|
-
# Applies class- or instance-level defaults and filters valid option keys.
|
128
|
+
# Includes the base accessor, optional writer, and optional predicate.
|
149
129
|
#
|
150
|
-
# @
|
151
|
-
|
152
|
-
|
153
|
-
defaults = type == :class ? DEFAULT_CLASS_ATTRIBUTE_OPTIONS : DEFAULT_INSTANCE_ATTRIBUTE_OPTIONS
|
154
|
-
defaults.merge(options.slice(*defaults.keys))
|
130
|
+
# @return [Array<Symbol>] a list of method names
|
131
|
+
def allowed_methods
|
132
|
+
[name, (:"#{name}=" if writable?), (:"#{name}?" if with_predicate?)].compact.freeze
|
155
133
|
end
|
156
134
|
|
157
|
-
#
|
135
|
+
# Validates whether this attribute is assignable in the current context.
|
158
136
|
#
|
159
|
-
# @
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
137
|
+
# @raise [Cattri::AttributeError] if assignment is disallowed
|
138
|
+
def validate_assignment!
|
139
|
+
if final?
|
140
|
+
raise Cattri::AttributeError, "Cannot assign to final attribute `:#{name}`"
|
141
|
+
elsif readonly?
|
142
|
+
raise Cattri::AttributeError, "Cannot assign to readonly attribute `:#{name}`"
|
143
|
+
end
|
164
144
|
end
|
165
145
|
|
166
|
-
#
|
167
|
-
# - Returns kwargs if given
|
168
|
-
# - Returns the single positional argument if one
|
169
|
-
# - Returns all args as an array otherwise
|
146
|
+
# Resolves the default value for this attribute.
|
170
147
|
#
|
171
|
-
# @
|
172
|
-
# @
|
173
|
-
def
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
args
|
179
|
-
}
|
148
|
+
# @return [Object] the evaluated default
|
149
|
+
# @raise [Cattri::AttributeError] if default evaluation fails
|
150
|
+
def evaluate_default
|
151
|
+
@options.default.call
|
152
|
+
rescue StandardError => e
|
153
|
+
raise Cattri::AttributeError, "Failed to evaluate the default value for `:#{@options.name}`. Error: #{e.message}"
|
180
154
|
end
|
181
155
|
|
182
|
-
#
|
156
|
+
# Processes and transforms an incoming assignment for this attribute.
|
183
157
|
#
|
184
|
-
#
|
185
|
-
#
|
186
|
-
#
|
187
|
-
#
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
return -> { default } if default.frozen? || SAFE_VALUE_TYPES.any? { |type| default.is_a?(type) }
|
193
|
-
|
194
|
-
lambda {
|
195
|
-
begin
|
196
|
-
default.dup
|
197
|
-
rescue StandardError => e
|
198
|
-
raise Cattri::AttributeError,
|
199
|
-
"Failed to duplicate default value for :#{name}. Error: #{e.message}"
|
200
|
-
end
|
201
|
-
}
|
158
|
+
# @param args [Array] positional arguments to pass to the transformer
|
159
|
+
# @param kwargs [Hash] keyword arguments to pass to the transformer
|
160
|
+
# @return [Object] the transformed value
|
161
|
+
# @raise [Cattri::AttributeError] if transformation fails
|
162
|
+
def process_assignment(*args, **kwargs)
|
163
|
+
@options.transformer.call(*args, **kwargs)
|
164
|
+
rescue StandardError => e
|
165
|
+
raise Cattri::AttributeError, "Failed to evaluate the setter for `:#{@options.name}`. Error: #{e.message}"
|
202
166
|
end
|
203
167
|
end
|
204
168
|
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cattri
|
4
|
+
# @internal
|
5
|
+
#
|
6
|
+
# Responsible for defining methods on the target class/module
|
7
|
+
# based on the metadata in a {Cattri::Attribute}.
|
8
|
+
#
|
9
|
+
# This includes:
|
10
|
+
# - callable accessors (acting as both reader and writer)
|
11
|
+
# - predicate methods
|
12
|
+
# - explicit writers (`:name=` methods)
|
13
|
+
#
|
14
|
+
# Handles both instance and class-level attributes, including
|
15
|
+
# memoization and validation of default values for final attributes.
|
16
|
+
class AttributeCompiler
|
17
|
+
class << self
|
18
|
+
# Defines accessor methods for the given attribute in the provided context.
|
19
|
+
#
|
20
|
+
# For `final` + `scope: :class` attributes, the default is eagerly assigned.
|
21
|
+
# Then, if permitted by `expose`, the reader, writer, and/or predicate methods are defined.
|
22
|
+
#
|
23
|
+
# @param attribute [Cattri::Attribute] the attribute to define
|
24
|
+
# @param context [Cattri::Context] the target context for method definition
|
25
|
+
# @return [void]
|
26
|
+
def define_accessor(attribute, context)
|
27
|
+
if attribute.class_attribute? && attribute.final?
|
28
|
+
value = attribute.evaluate_default
|
29
|
+
context.target.cattri_variable_set(attribute.ivar, value) # steep:ignore
|
30
|
+
end
|
31
|
+
|
32
|
+
return if attribute.expose == :none
|
33
|
+
|
34
|
+
define_accessor!(attribute, context)
|
35
|
+
define_writer!(attribute, context)
|
36
|
+
define_predicate!(attribute, context) if attribute.with_predicate?
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
# Defines a callable method that acts as both getter and setter.
|
42
|
+
#
|
43
|
+
# If called with no arguments, it returns the default (memoized).
|
44
|
+
# If called with arguments, it processes the assignment and writes the value.
|
45
|
+
#
|
46
|
+
# @param attribute [Cattri::Attribute]
|
47
|
+
# @param context [Cattri::Context]
|
48
|
+
# @return [void]
|
49
|
+
def define_accessor!(attribute, context)
|
50
|
+
context.define_method(attribute) do |*args, **kwargs|
|
51
|
+
readonly_call = args.empty? && kwargs.empty?
|
52
|
+
return AttributeCompiler.send(:memoize_default_value, self, attribute) if readonly_call
|
53
|
+
|
54
|
+
attribute.validate_assignment!
|
55
|
+
value = attribute.process_assignment(*args, **kwargs)
|
56
|
+
cattri_variable_set(attribute.ivar, value) # steep:ignore
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Defines a writer method `:name=`, assigning a transformed value to the backing store.
|
61
|
+
#
|
62
|
+
# @param attribute [Cattri::Attribute]
|
63
|
+
# @param context [Cattri::Context]
|
64
|
+
# @return [void]
|
65
|
+
def define_writer!(attribute, context)
|
66
|
+
context.define_method(attribute, name: :"#{attribute.name}=") do |value|
|
67
|
+
coerced_value = attribute.process_assignment(value)
|
68
|
+
cattri_variable_set(attribute.ivar, coerced_value, final: attribute.final?) # steep:ignore
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Defines a predicate method `:name?` that returns the truthiness of the value.
|
73
|
+
#
|
74
|
+
# @param attribute [Cattri::Attribute]
|
75
|
+
# @param context [Cattri::Context]
|
76
|
+
# @return [void]
|
77
|
+
def define_predicate!(attribute, context)
|
78
|
+
context.define_method(attribute, name: :"#{attribute.name}?") do
|
79
|
+
!!send(attribute.name) # rubocop:disable Style/DoubleNegation
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Returns the default value for the attribute, memoizing it in the backing store.
|
84
|
+
#
|
85
|
+
# For `final` attributes, raises unless explicitly initialized.
|
86
|
+
#
|
87
|
+
# @param receiver [Object] the instance or class receiving the value
|
88
|
+
# @param attribute [Cattri::Attribute]
|
89
|
+
# @return [Object] the stored or evaluated default
|
90
|
+
# @raise [Cattri::AttributeError] if final attribute is unset or evaluation fails
|
91
|
+
def memoize_default_value(receiver, attribute)
|
92
|
+
if attribute.final?
|
93
|
+
return receiver.cattri_variable_get(attribute.ivar) if receiver.cattri_variable_defined?(attribute.ivar)
|
94
|
+
|
95
|
+
raise Cattri::AttributeError, "Final attribute :#{attribute.name} cannot be written to"
|
96
|
+
end
|
97
|
+
|
98
|
+
receiver.cattri_variable_memoize(attribute.ivar, final: attribute.final?) do
|
99
|
+
attribute.evaluate_default
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,183 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "error"
|
4
|
+
|
5
|
+
module Cattri
|
6
|
+
# @internal
|
7
|
+
#
|
8
|
+
# AttributeOptions encapsulates normalized metadata for a single Cattri-defined attribute.
|
9
|
+
#
|
10
|
+
# It validates, transforms, and freezes all input during initialization,
|
11
|
+
# ensuring attribute safety and immutability at runtime.
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# options = AttributeOptions.new(:enabled, default: true, expose: :read_write)
|
15
|
+
# options.name # => :enabled
|
16
|
+
# options.default.call # => true
|
17
|
+
# options.expose # => :read_write
|
18
|
+
class AttributeOptions
|
19
|
+
class << self
|
20
|
+
# Validates and normalizes the `expose` configuration.
|
21
|
+
#
|
22
|
+
# @param expose [Symbol, String] one of: :read, :write, :read_write, :none
|
23
|
+
# @return [Symbol]
|
24
|
+
# @raise [Cattri::AttributeError] if the value is invalid
|
25
|
+
def validate_expose!(expose)
|
26
|
+
expose = expose.to_sym
|
27
|
+
return expose if EXPOSE_OPTIONS.include?(expose) # steep:ignore
|
28
|
+
|
29
|
+
raise Cattri::AttributeError, "Invalid expose option `#{expose.inspect}` for :#{name}"
|
30
|
+
end
|
31
|
+
|
32
|
+
# Validates and normalizes method visibility.
|
33
|
+
#
|
34
|
+
# @param visibility [Symbol, String] one of: :public, :protected, :private
|
35
|
+
# @return [Symbol]
|
36
|
+
# @raise [Cattri::AttributeError] if the value is invalid
|
37
|
+
def validate_visibility!(visibility)
|
38
|
+
visibility = visibility.to_sym
|
39
|
+
return visibility if VISIBILITIES.include?(visibility) # steep:ignore
|
40
|
+
|
41
|
+
raise Cattri::AttributeError, "Invalid visibility `#{visibility.inspect}` for :#{name}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Valid method visibility levels.
|
46
|
+
VISIBILITIES = %i[public protected private].freeze
|
47
|
+
|
48
|
+
# Valid expose options for method generation.
|
49
|
+
EXPOSE_OPTIONS = %i[read write read_write none].freeze
|
50
|
+
|
51
|
+
# Valid scope types.
|
52
|
+
SCOPES = %i[class instance].freeze
|
53
|
+
private_constant :SCOPES
|
54
|
+
|
55
|
+
# Built-in Ruby value types that are safe to reuse as-is (no dup needed).
|
56
|
+
SAFE_VALUE_TYPES = [Numeric, Symbol, TrueClass, FalseClass, NilClass].freeze
|
57
|
+
private_constant :SAFE_VALUE_TYPES
|
58
|
+
|
59
|
+
attr_reader :name, :ivar, :final, :scope, :predicate,
|
60
|
+
:default, :transformer, :expose, :visibility
|
61
|
+
|
62
|
+
# Initializes a frozen attribute configuration.
|
63
|
+
#
|
64
|
+
# @param name [Symbol, String] the attribute name
|
65
|
+
# @param ivar [Symbol, String, nil] optional custom instance variable name
|
66
|
+
# @param final [Boolean] marks the attribute as write-once
|
67
|
+
# @param scope [Symbol] indicates if the attribute is class-level (:class) or instance-level (:instance)
|
68
|
+
# @param predicate [Boolean] whether to define a `?` predicate method
|
69
|
+
# @param default [Object, Proc, nil] default value or callable
|
70
|
+
# @param transformer [Proc, nil] optional coercion block
|
71
|
+
# @param expose [Symbol] access level to define (:read, :write, :read_write, :none)
|
72
|
+
# @param visibility [Symbol] method visibility (:public, :protected, :private)
|
73
|
+
def initialize(
|
74
|
+
name,
|
75
|
+
ivar: nil,
|
76
|
+
final: false,
|
77
|
+
scope: :instance,
|
78
|
+
predicate: false,
|
79
|
+
default: nil,
|
80
|
+
transformer: nil,
|
81
|
+
expose: :read_write,
|
82
|
+
visibility: :public
|
83
|
+
)
|
84
|
+
@name = name.to_sym
|
85
|
+
@ivar = normalize_ivar(ivar)
|
86
|
+
@final = final
|
87
|
+
@scope = validate_scope!(scope)
|
88
|
+
@predicate = predicate
|
89
|
+
@default = normalize_default(default)
|
90
|
+
@transformer = normalize_transformer(transformer)
|
91
|
+
@expose = self.class.validate_expose!(expose)
|
92
|
+
@visibility = self.class.validate_visibility!(visibility)
|
93
|
+
|
94
|
+
freeze
|
95
|
+
end
|
96
|
+
|
97
|
+
# Returns a frozen hash representation of this option set.
|
98
|
+
#
|
99
|
+
# @return [Hash<Symbol, Object>]
|
100
|
+
def to_h
|
101
|
+
hash = {
|
102
|
+
name: @name,
|
103
|
+
ivar: @ivar,
|
104
|
+
final: @final,
|
105
|
+
scope: @scope,
|
106
|
+
predicate: @predicate,
|
107
|
+
default: @default,
|
108
|
+
transformer: @transformer,
|
109
|
+
expose: @expose,
|
110
|
+
visibility: @visibility
|
111
|
+
}
|
112
|
+
hash.freeze
|
113
|
+
hash
|
114
|
+
end
|
115
|
+
|
116
|
+
# Allows hash-style access to the option set.
|
117
|
+
#
|
118
|
+
# @param key [Symbol, String]
|
119
|
+
# @return [Object]
|
120
|
+
def [](key)
|
121
|
+
to_h[key.to_sym]
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
# Normalizes the instance variable name, defaulting to @name.
|
127
|
+
#
|
128
|
+
# @param ivar [String, Symbol, nil]
|
129
|
+
# @return [Symbol]
|
130
|
+
def normalize_ivar(ivar)
|
131
|
+
ivar ||= name
|
132
|
+
:"@#{ivar.to_s.delete_prefix("@")}"
|
133
|
+
end
|
134
|
+
|
135
|
+
# Wraps the default in a Proc with immutability protection.
|
136
|
+
#
|
137
|
+
# - Returns original Proc if given.
|
138
|
+
# - Wraps immutable types as-is.
|
139
|
+
# - Duplicates mutable values at runtime.
|
140
|
+
#
|
141
|
+
# @param default [Object, Proc, nil]
|
142
|
+
# @return [Proc]
|
143
|
+
def normalize_default(default)
|
144
|
+
return default if default.is_a?(Proc)
|
145
|
+
return -> { default } if default.frozen? || SAFE_VALUE_TYPES.any? { |t| default.is_a?(t) }
|
146
|
+
|
147
|
+
-> { default.dup }
|
148
|
+
end
|
149
|
+
|
150
|
+
# Returns a normalized assignment transformer.
|
151
|
+
#
|
152
|
+
# Falls back to a default transformer that returns:
|
153
|
+
# - `kwargs` if `args.empty?`
|
154
|
+
# - the single argument if one is passed
|
155
|
+
# - `[*args, kwargs]` otherwise
|
156
|
+
#
|
157
|
+
# @param transformer [Proc, nil]
|
158
|
+
# @return [Proc]
|
159
|
+
def normalize_transformer(transformer)
|
160
|
+
transformer || lambda { |*args, **kwargs|
|
161
|
+
return kwargs if args.empty?
|
162
|
+
return args.length == 1 ? args[0] : args if kwargs.empty?
|
163
|
+
|
164
|
+
[*args, kwargs]
|
165
|
+
}
|
166
|
+
end
|
167
|
+
|
168
|
+
# Validates and normalizes the provided scope value.
|
169
|
+
#
|
170
|
+
# If `scope` is `nil`, it defaults to `:instance`. If it's one of the allowed
|
171
|
+
# values (`:class`, `:instance`), it is returned as-is. Otherwise, an error is raised.
|
172
|
+
#
|
173
|
+
# @param scope [Symbol, nil] the requested attribute scope
|
174
|
+
# @return [Symbol] the validated scope (`:class` or `:instance`)
|
175
|
+
# @raise [Cattri::AttributeError] if the scope is invalid
|
176
|
+
def validate_scope!(scope)
|
177
|
+
return :instance if scope.nil?
|
178
|
+
return scope if SCOPES.include?(scope)
|
179
|
+
|
180
|
+
raise Cattri::AttributeError, "Invalid scope `#{scope.inspect}` for :#{name}. Must be :class or :instance"
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|