cattri 0.2.1 → 0.2.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 +4 -4
- data/CHANGELOG.md +43 -1
- data/lib/cattri/attribute.rb +1 -1
- data/lib/cattri/attribute_compiler.rb +9 -14
- data/lib/cattri/attribute_options.rb +10 -6
- data/lib/cattri/attribute_registry.rb +0 -17
- data/lib/cattri/context.rb +80 -17
- data/lib/cattri/context_registry.rb +2 -1
- data/lib/cattri/initializer_patch.rb +1 -1
- data/lib/cattri/internal_store.rb +11 -0
- data/lib/cattri/introspection.rb +5 -1
- data/lib/cattri/version.rb +1 -1
- data/lib/cattri/visibility.rb +3 -3
- data/lib/cattri.rb +4 -3
- data/sig/lib/cattri/attribute_options.rbs +2 -2
- data/sig/lib/cattri/context.rbs +34 -3
- data/sig/lib/cattri/internal_store.rbs +9 -0
- data/spec/cattri/attribute_compiler_spec.rb +30 -40
- data/spec/cattri/attribute_options_spec.rb +12 -0
- data/spec/cattri/attribute_registry_spec.rb +0 -37
- data/spec/cattri/attribute_spec.rb +4 -4
- data/spec/cattri/context_registry_spec.rb +1 -0
- data/spec/cattri/context_spec.rb +128 -11
- data/spec/cattri/internal_store_spec.rb +10 -0
- data/spec/cattri/introspection_spec.rb +8 -0
- data/spec/cattri_spec.rb +187 -0
- metadata +2 -5
- data/lib/cattri/inheritance.rb +0 -35
- data/sig/lib/cattri/inheritance.rbs +0 -21
- data/spec/cattri/inheritance_spec.rb +0 -60
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba04c1c408b5ffe93591ba80633bc3b7d7950d0e651e4adf738e30fe4ba795ea
|
|
4
|
+
data.tar.gz: a4d5f788238eee82419df14b8977b018ed8b7d91169f7719171186c26e9dde7d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a923822a64d948f87bc16c14511ab2b9a6d278f3b3a9264ee4ab57515644f3f42dedaee7db4bd3b6d5a7d294daf866a00dd99b0d4d72b29faade58f6facc0e2
|
|
7
|
+
data.tar.gz: 26cc1589b802f4916b55b43312d567e7e68138a83e16780c6c5a49405b16e45a6325bfd5d5e39b7b80bbe5bdf53bbadd84b606765c156cf53183be6a49e251f9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,50 @@
|
|
|
1
|
+
## [0.2.3] - 2025-12-14
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
- New specs covering write-only exposure visibility for both instance and class attributes, ensuring readers are properly private/protected while writers remain public.
|
|
5
|
+
- Strengthened Context storage resolution tests to assert descriptive errors when instance receivers are missing.
|
|
6
|
+
|
|
7
|
+
## [0.2.2] - 2025-05-04
|
|
8
|
+
|
|
9
|
+
No breaking changes – the public DSL (cattri, final_cattri) remains identical to v0.2.0.
|
|
10
|
+
|
|
11
|
+
### Removed
|
|
12
|
+
- Removed `Cattri::AttributeRegistry#copy_attributes_to` and all associated logic. Subclass attribute propagation
|
|
13
|
+
is now handled entirely via runtime resolution in `Context#storage_receiver_for`, eliminating the need for eager
|
|
14
|
+
copying of attribute values or metadata.
|
|
15
|
+
- Removed `Cattri::Inheritance` and its use of `.inherited` hooks for subclass propagation, since all relevant state
|
|
16
|
+
is now derived dynamically.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Replaced static subclass propagation with dynamic storage resolution using `Context#storage_receiver_for`,
|
|
20
|
+
which now determines the correct backing object (instance, class, or singleton class) for all reads and writes.
|
|
21
|
+
- `AttributeCompiler.define_accessor` now uses `storage_receiver_for` to set final class-level attribute values
|
|
22
|
+
during compilation.
|
|
23
|
+
- Final class-level attributes (`final: true, scope: :class`) are now evaluated and written directly to the correct
|
|
24
|
+
storage receiver without relying on prior duplication logic.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- Proper support for defining attributes directly on a module’s singleton_class, ensuring values are stored and
|
|
28
|
+
accessed consistently across runtime and functional tests.
|
|
29
|
+
```ruby
|
|
30
|
+
module MyModule
|
|
31
|
+
class << self
|
|
32
|
+
include Cattri
|
|
33
|
+
|
|
34
|
+
cattri :version, "0.1.0", final: true, scope: :class
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
- Regression tests ensuring:
|
|
39
|
+
- Subclass attribute shadowing works for `final: true, scope: :class`.
|
|
40
|
+
- Instance and class attribute isolation behaves as expected across inheritance chains.
|
|
41
|
+
- No mutation of final attributes once defined.
|
|
42
|
+
- Test coverage for `Context#storage_receiver` for and `Context#resolve_class_storage_for` across all branching logic.
|
|
43
|
+
|
|
1
44
|
## [0.2.1] - 2025-05-01
|
|
2
45
|
|
|
3
46
|
- Fixed an issue where only `final: true` instance variables defined on the current/class had their values applied.
|
|
4
47
|
- Now walks the ancestor tree to ensure all attributes get set.
|
|
5
|
-
|
|
6
48
|
```ruby
|
|
7
49
|
module Options
|
|
8
50
|
include Cattri
|
data/lib/cattri/attribute.rb
CHANGED
|
@@ -26,13 +26,14 @@ module Cattri
|
|
|
26
26
|
def define_accessor(attribute, context)
|
|
27
27
|
if attribute.class_attribute? && attribute.final?
|
|
28
28
|
value = attribute.evaluate_default
|
|
29
|
-
context.
|
|
29
|
+
context.storage_receiver_for(attribute) # steep:ignore
|
|
30
|
+
.cattri_variable_set(attribute.ivar, value, final: attribute.final?) # steep:ignore
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
return if attribute.expose == :none
|
|
33
34
|
|
|
34
35
|
define_accessor!(attribute, context)
|
|
35
|
-
define_writer!(attribute, context)
|
|
36
|
+
define_writer!(attribute, context) if attribute.writable?
|
|
36
37
|
define_predicate!(attribute, context) if attribute.with_predicate?
|
|
37
38
|
end
|
|
38
39
|
|
|
@@ -48,12 +49,13 @@ module Cattri
|
|
|
48
49
|
# @return [void]
|
|
49
50
|
def define_accessor!(attribute, context)
|
|
50
51
|
context.define_method(attribute) do |*args, **kwargs|
|
|
52
|
+
receiver = context.storage_receiver_for(attribute, self)
|
|
51
53
|
readonly_call = args.empty? && kwargs.empty?
|
|
52
|
-
return AttributeCompiler.send(:memoize_default_value,
|
|
54
|
+
return AttributeCompiler.send(:memoize_default_value, receiver, attribute) if readonly_call
|
|
53
55
|
|
|
54
56
|
attribute.validate_assignment!
|
|
55
57
|
value = attribute.process_assignment(*args, **kwargs)
|
|
56
|
-
cattri_variable_set(attribute.ivar, value) # steep:ignore
|
|
58
|
+
receiver.cattri_variable_set(attribute.ivar, value) # steep:ignore
|
|
57
59
|
end
|
|
58
60
|
end
|
|
59
61
|
|
|
@@ -64,8 +66,10 @@ module Cattri
|
|
|
64
66
|
# @return [void]
|
|
65
67
|
def define_writer!(attribute, context)
|
|
66
68
|
context.define_method(attribute, name: :"#{attribute.name}=") do |value|
|
|
69
|
+
receiver = context.storage_receiver_for(attribute, self)
|
|
70
|
+
|
|
67
71
|
coerced_value = attribute.process_assignment(value)
|
|
68
|
-
cattri_variable_set(attribute.ivar, coerced_value, final: attribute.final?) # steep:ignore
|
|
72
|
+
receiver.cattri_variable_set(attribute.ivar, coerced_value, final: attribute.final?) # steep:ignore
|
|
69
73
|
end
|
|
70
74
|
end
|
|
71
75
|
|
|
@@ -82,19 +86,10 @@ module Cattri
|
|
|
82
86
|
|
|
83
87
|
# Returns the default value for the attribute, memoizing it in the backing store.
|
|
84
88
|
#
|
|
85
|
-
# For `final` attributes, raises unless explicitly initialized.
|
|
86
|
-
#
|
|
87
89
|
# @param receiver [Object] the instance or class receiving the value
|
|
88
90
|
# @param attribute [Cattri::Attribute]
|
|
89
91
|
# @return [Object] the stored or evaluated default
|
|
90
|
-
# @raise [Cattri::AttributeError] if final attribute is unset or evaluation fails
|
|
91
92
|
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
93
|
receiver.cattri_variable_memoize(attribute.ivar, final: attribute.final?) do
|
|
99
94
|
attribute.evaluate_default
|
|
100
95
|
end
|
|
@@ -20,25 +20,29 @@ module Cattri
|
|
|
20
20
|
# Validates and normalizes the `expose` configuration.
|
|
21
21
|
#
|
|
22
22
|
# @param expose [Symbol, String] one of: :read, :write, :read_write, :none
|
|
23
|
+
# @param attribute_name [Symbol, nil] optional attribute name for error context
|
|
23
24
|
# @return [Symbol]
|
|
24
25
|
# @raise [Cattri::AttributeError] if the value is invalid
|
|
25
|
-
def validate_expose!(expose)
|
|
26
|
+
def validate_expose!(expose, attribute_name: nil)
|
|
26
27
|
expose = expose.to_sym
|
|
27
28
|
return expose if EXPOSE_OPTIONS.include?(expose) # steep:ignore
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
detail = attribute_name ? " for :#{attribute_name}" : ""
|
|
31
|
+
raise Cattri::AttributeError, "Invalid expose option `#{expose.inspect}`#{detail}"
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
# Validates and normalizes method visibility.
|
|
33
35
|
#
|
|
34
36
|
# @param visibility [Symbol, String] one of: :public, :protected, :private
|
|
37
|
+
# @param attribute_name [Symbol, nil] optional attribute name for error context
|
|
35
38
|
# @return [Symbol]
|
|
36
39
|
# @raise [Cattri::AttributeError] if the value is invalid
|
|
37
|
-
def validate_visibility!(visibility)
|
|
40
|
+
def validate_visibility!(visibility, attribute_name: nil)
|
|
38
41
|
visibility = visibility.to_sym
|
|
39
42
|
return visibility if VISIBILITIES.include?(visibility) # steep:ignore
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
detail = attribute_name ? " for :#{attribute_name}" : ""
|
|
45
|
+
raise Cattri::AttributeError, "Invalid visibility `#{visibility.inspect}`#{detail}"
|
|
42
46
|
end
|
|
43
47
|
end
|
|
44
48
|
|
|
@@ -88,8 +92,8 @@ module Cattri
|
|
|
88
92
|
@predicate = predicate
|
|
89
93
|
@default = normalize_default(default)
|
|
90
94
|
@transformer = normalize_transformer(transformer)
|
|
91
|
-
@expose = self.class.validate_expose!(expose)
|
|
92
|
-
@visibility = self.class.validate_visibility!(visibility)
|
|
95
|
+
@expose = self.class.validate_expose!(expose, attribute_name: @name)
|
|
96
|
+
@visibility = self.class.validate_visibility!(visibility, attribute_name: @name)
|
|
93
97
|
|
|
94
98
|
freeze
|
|
95
99
|
end
|
|
@@ -87,23 +87,6 @@ module Cattri
|
|
|
87
87
|
attribute.allowed_methods
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
# Copies registered attributes from this context to another,
|
|
91
|
-
# preserving definitions and assigning values for `final: true, scope: :class`.
|
|
92
|
-
#
|
|
93
|
-
# @param target_context [Cattri::Context]
|
|
94
|
-
# @return [void]
|
|
95
|
-
def copy_attributes_to(target_context)
|
|
96
|
-
registered_attributes.each_value do |attribute|
|
|
97
|
-
next unless attribute.class_attribute? && attribute.final?
|
|
98
|
-
|
|
99
|
-
target_registry = target_context.target.send(:attribute_registry)
|
|
100
|
-
target_registry.send(:register_attribute, attribute)
|
|
101
|
-
|
|
102
|
-
value = context.target.cattri_variable_get(attribute.ivar) # steep:ignore
|
|
103
|
-
target_context.target.cattri_variable_set(attribute.ivar, value) # steep:ignore
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
|
|
107
90
|
private
|
|
108
91
|
|
|
109
92
|
# Validates that no attribute with the same name is already registered.
|
data/lib/cattri/context.rb
CHANGED
|
@@ -22,7 +22,9 @@ module Cattri
|
|
|
22
22
|
# @return [Module]
|
|
23
23
|
attr_reader :target
|
|
24
24
|
|
|
25
|
-
#
|
|
25
|
+
# Initializes the context wrapper.
|
|
26
|
+
#
|
|
27
|
+
# @param target [Module, Class] the receiver where attributes are defined
|
|
26
28
|
def initialize(target)
|
|
27
29
|
@target = target
|
|
28
30
|
end
|
|
@@ -102,6 +104,44 @@ module Cattri
|
|
|
102
104
|
__cattri_defined_methods[attribute.name].include?(normalized_name)
|
|
103
105
|
end
|
|
104
106
|
|
|
107
|
+
# Resolves and returns the module or class where methods and storage should be defined
|
|
108
|
+
# for the given attribute. Ensures the internal store is included on the resolved target.
|
|
109
|
+
#
|
|
110
|
+
# - For class-level attributes, this returns the singleton class unless it's already a singleton.
|
|
111
|
+
# - For instance-level attributes, this returns the class/module directly.
|
|
112
|
+
#
|
|
113
|
+
# @param attribute [Cattri::Attribute]
|
|
114
|
+
# @return [Module]
|
|
115
|
+
def target_for(attribute)
|
|
116
|
+
return @target if attribute.class_attribute? && singleton_class?(@target)
|
|
117
|
+
|
|
118
|
+
attribute.class_attribute? ? @target.singleton_class : @target
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Determines the object (class/module or runtime instance) that should hold
|
|
122
|
+
# the backing storage for the given attribute.
|
|
123
|
+
#
|
|
124
|
+
# - For class-level attributes, uses the singleton class of `defined_in` or the module itself
|
|
125
|
+
# - For instance-level attributes, uses the provided instance
|
|
126
|
+
#
|
|
127
|
+
# @param attribute [Cattri::Attribute]
|
|
128
|
+
# @param instance [Object, nil] the runtime instance, if needed for instance-level access
|
|
129
|
+
# @return [Object] the receiver for attribute value storage
|
|
130
|
+
# @raise [Cattri::Error] if instance is required but missing
|
|
131
|
+
def storage_receiver_for(attribute, instance = nil)
|
|
132
|
+
receiver =
|
|
133
|
+
if attribute.class_attribute?
|
|
134
|
+
resolve_class_storage_for(attribute, instance)
|
|
135
|
+
elsif instance
|
|
136
|
+
instance
|
|
137
|
+
else
|
|
138
|
+
raise Cattri::Error, "Missing runtime instance for instance-level attribute :#{attribute.name}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
install_internal_store!(receiver)
|
|
142
|
+
receiver
|
|
143
|
+
end
|
|
144
|
+
|
|
105
145
|
private
|
|
106
146
|
|
|
107
147
|
# Internal tracking of explicitly defined methods per attribute.
|
|
@@ -111,12 +151,42 @@ module Cattri
|
|
|
111
151
|
@__cattri_defined_methods ||= Hash.new { |h, k| h[k] = Set.new }
|
|
112
152
|
end
|
|
113
153
|
|
|
114
|
-
# Determines whether
|
|
154
|
+
# Determines whether the object is a singleton_class or not.
|
|
155
|
+
#
|
|
156
|
+
# @param obj [Object]
|
|
157
|
+
# @return [Boolean]
|
|
158
|
+
def singleton_class?(obj)
|
|
159
|
+
obj.singleton_class? # Ruby 3.2+
|
|
160
|
+
rescue NoMethodError
|
|
161
|
+
obj.inspect.start_with?("#<Class:")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Resolves the proper class-level storage receiver for the attribute.
|
|
115
165
|
#
|
|
116
166
|
# @param attribute [Cattri::Attribute]
|
|
117
|
-
# @
|
|
118
|
-
|
|
119
|
-
|
|
167
|
+
# @param instance [Object, nil]
|
|
168
|
+
# @return [Object]
|
|
169
|
+
def resolve_class_storage_for(attribute, instance)
|
|
170
|
+
if attribute.final?
|
|
171
|
+
singleton_class?(attribute.defined_in) ? attribute.defined_in : attribute.defined_in.singleton_class
|
|
172
|
+
else
|
|
173
|
+
attribute_scope = instance || attribute.defined_in
|
|
174
|
+
attribute_scope.singleton_class
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Installs the internal store on the receiver if not present.
|
|
179
|
+
#
|
|
180
|
+
# @param receiver [Object]
|
|
181
|
+
# @return [void]
|
|
182
|
+
def install_internal_store!(receiver)
|
|
183
|
+
return if receiver.respond_to?(:cattri_variables)
|
|
184
|
+
|
|
185
|
+
if singleton_class?(receiver)
|
|
186
|
+
receiver.extend(Cattri::InternalStore)
|
|
187
|
+
else
|
|
188
|
+
receiver.include(Cattri::InternalStore) # steep:ignore
|
|
189
|
+
end
|
|
120
190
|
end
|
|
121
191
|
|
|
122
192
|
# Defines the method and applies its access visibility.
|
|
@@ -157,11 +227,9 @@ module Cattri
|
|
|
157
227
|
# - Returns `:private` for instance-level attributes
|
|
158
228
|
# - Otherwise, returns the explicitly declared visibility (`attribute.visibility`)
|
|
159
229
|
#
|
|
160
|
-
# This ensures that internal-only attributes remain inaccessible outside their scope,
|
|
161
|
-
# while still being usable by subclasses if class-level.
|
|
162
|
-
#
|
|
163
230
|
# @param attribute [Cattri::Attribute]
|
|
164
|
-
# @
|
|
231
|
+
# @param name [Symbol]
|
|
232
|
+
# @return [Symbol] one of `:public`, `:protected`, or `:private`
|
|
165
233
|
def effective_visibility(attribute, name)
|
|
166
234
|
return :protected if attribute.class_attribute? && internal_method?(attribute, name)
|
|
167
235
|
return :private if !attribute.class_attribute? && internal_method?(attribute, name)
|
|
@@ -172,14 +240,9 @@ module Cattri
|
|
|
172
240
|
# Determines whether the given method name (accessor or writer)
|
|
173
241
|
# should be treated as internal-only based on the attribute's `expose` configuration.
|
|
174
242
|
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
# - Reader methods (`:attr`) are considered internal if the attribute lacks public write access.
|
|
179
|
-
#
|
|
180
|
-
# @param attribute [Cattri::Attribute] the attribute definition
|
|
181
|
-
# @param name [Symbol, String] the method name being defined
|
|
182
|
-
# @return [Boolean] true if the method should be scoped for internal use only
|
|
243
|
+
# @param attribute [Cattri::Attribute]
|
|
244
|
+
# @param name [Symbol, String]
|
|
245
|
+
# @return [Boolean]
|
|
183
246
|
def internal_method?(attribute, name)
|
|
184
247
|
return attribute.internal_writer? if name.to_s.end_with?("=")
|
|
185
248
|
|
|
@@ -30,7 +30,8 @@ module Cattri
|
|
|
30
30
|
#
|
|
31
31
|
# @return [Cattri::Context] the context used for method definition and visibility tracking
|
|
32
32
|
def context
|
|
33
|
-
|
|
33
|
+
base = instance_variable_get(:@__cattri_base_target) || self
|
|
34
|
+
@context ||= Context.new(base) # steep:ignore
|
|
34
35
|
end
|
|
35
36
|
end
|
|
36
37
|
end
|
|
@@ -31,7 +31,7 @@ module Cattri
|
|
|
31
31
|
next if cattri_variable_defined?(attribute.ivar) # steep:ignore
|
|
32
32
|
next unless attribute.final?
|
|
33
33
|
|
|
34
|
-
cattri_variable_set(attribute.ivar, attribute.evaluate_default) # steep:ignore
|
|
34
|
+
cattri_variable_set(attribute.ivar, attribute.evaluate_default, final: true) # steep:ignore
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
end
|
|
@@ -13,6 +13,17 @@ module Cattri
|
|
|
13
13
|
#
|
|
14
14
|
# It supports enforcement of `final` semantics and tracks explicit assignments.
|
|
15
15
|
module InternalStore
|
|
16
|
+
# Returns the list of attribute keys stored by Cattri on this object.
|
|
17
|
+
#
|
|
18
|
+
# Mimics Ruby's `#instance_variables`, but only includes attributes defined
|
|
19
|
+
# via `cattri` and omits the leading `@` from names. All keys are returned as
|
|
20
|
+
# frozen symbols (e.g., `:enabled` instead of `:@enabled`).
|
|
21
|
+
#
|
|
22
|
+
# @return [Array<Symbol>] the list of internally tracked attribute keys
|
|
23
|
+
def cattri_variables
|
|
24
|
+
__cattri_store.keys.freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
16
27
|
# Checks whether the internal store contains a value for the given key.
|
|
17
28
|
#
|
|
18
29
|
# @param key [String, Symbol] the attribute name or instance variable
|
data/lib/cattri/introspection.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
3
5
|
module Cattri
|
|
4
6
|
# Provides a read-only interface for inspecting attributes defined via the Cattri DSL.
|
|
5
7
|
#
|
|
@@ -62,7 +64,9 @@ module Cattri
|
|
|
62
64
|
#
|
|
63
65
|
# @return [Hash{Symbol => Set<Symbol>}]
|
|
64
66
|
def attribute_methods
|
|
65
|
-
|
|
67
|
+
attribute_registry.defined_attributes(with_ancestors: true).transform_values do |attribute| # steep:ignore
|
|
68
|
+
Set.new(attribute.allowed_methods)
|
|
69
|
+
end
|
|
66
70
|
end
|
|
67
71
|
|
|
68
72
|
# Returns the original class or module where the given attribute was defined.
|
data/lib/cattri/version.rb
CHANGED
data/lib/cattri/visibility.rb
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module Cattri
|
|
4
4
|
# Cattri::Visibility tracks the current method visibility context (`public`, `protected`, `private`)
|
|
5
5
|
# when defining methods dynamically. It mimics Ruby's native visibility behavior so that
|
|
6
|
-
# `
|
|
7
|
-
#
|
|
6
|
+
# `cattri` definitions can automatically infer the intended access level based on the current context
|
|
7
|
+
# in the source file.
|
|
8
8
|
#
|
|
9
9
|
# This module is intended to be extended by classes that include or extend Cattri.
|
|
10
10
|
#
|
|
@@ -13,7 +13,7 @@ module Cattri
|
|
|
13
13
|
# include Cattri
|
|
14
14
|
#
|
|
15
15
|
# private
|
|
16
|
-
#
|
|
16
|
+
# cattri :sensitive_data
|
|
17
17
|
# end
|
|
18
18
|
#
|
|
19
19
|
# # => :sensitive_data will be defined as a private method
|
data/lib/cattri.rb
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
require_relative "cattri/attribute"
|
|
4
4
|
require_relative "cattri/context_registry"
|
|
5
5
|
require_relative "cattri/dsl"
|
|
6
|
-
require_relative "cattri/inheritance"
|
|
7
6
|
require_relative "cattri/initializer_patch"
|
|
8
7
|
require_relative "cattri/internal_store"
|
|
9
8
|
require_relative "cattri/introspection"
|
|
@@ -33,14 +32,16 @@ module Cattri
|
|
|
33
32
|
[base, base.singleton_class].each do |mod|
|
|
34
33
|
mod.include(Cattri::InternalStore)
|
|
35
34
|
mod.include(Cattri::ContextRegistry)
|
|
35
|
+
mod.instance_variable_set(:@__cattri_base_target, base)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
base.extend(Cattri::InternalStore)
|
|
39
|
+
base.singleton_class.extend(Cattri::InternalStore)
|
|
40
|
+
|
|
38
41
|
base.prepend(Cattri::InitializerPatch)
|
|
39
42
|
base.extend(Cattri::Visibility)
|
|
40
43
|
base.extend(Cattri::Dsl)
|
|
41
44
|
base.extend(ClassMethods)
|
|
42
|
-
|
|
43
|
-
Cattri::Inheritance.install(base)
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
# Provides opt-in class-level introspection support.
|
|
@@ -35,14 +35,14 @@ module Cattri
|
|
|
35
35
|
# @param expose [Symbol, String] one of: :read, :write, :read_write, :none
|
|
36
36
|
# @return [Symbol]
|
|
37
37
|
# @raise [Cattri::AttributeError] if the value is invalid
|
|
38
|
-
def self.validate_expose!: (expose_types | identifier expose) -> untyped
|
|
38
|
+
def self.validate_expose!: (expose_types | identifier expose, ?attribute_name: ::Symbol?) -> untyped
|
|
39
39
|
|
|
40
40
|
# Validates and normalizes method visibility.
|
|
41
41
|
#
|
|
42
42
|
# @param visibility [Symbol, String] one of: :public, :protected, :private
|
|
43
43
|
# @return [Symbol]
|
|
44
44
|
# @raise [Cattri::AttributeError] if the value is invalid
|
|
45
|
-
def self.validate_visibility!: (visibility_types | identifier visibility) -> untyped
|
|
45
|
+
def self.validate_visibility!: (visibility_types | identifier visibility, ?attribute_name: ::Symbol?) -> untyped
|
|
46
46
|
|
|
47
47
|
# Valid method visibility levels.
|
|
48
48
|
VISIBILITIES: ::Array[visibility_types]
|
data/sig/lib/cattri/context.rbs
CHANGED
|
@@ -68,6 +68,24 @@ module Cattri
|
|
|
68
68
|
# @return [Boolean]
|
|
69
69
|
def method_defined?: (Attribute attribute, ?name: identifier?) -> bool
|
|
70
70
|
|
|
71
|
+
# Determines whether to define the method on the instance or singleton.
|
|
72
|
+
#
|
|
73
|
+
# @param attribute [Cattri::Attribute]
|
|
74
|
+
# @return [Module]
|
|
75
|
+
def target_for: (Attribute attribute) -> ::Module
|
|
76
|
+
|
|
77
|
+
# Determines the object (class/module or runtime instance) that should hold
|
|
78
|
+
# the backing storage for the given attribute.
|
|
79
|
+
#
|
|
80
|
+
# - For class-level attributes, uses the singleton class of `defined_in` or the module itself
|
|
81
|
+
# - For instance-level attributes, uses the provided instance
|
|
82
|
+
#
|
|
83
|
+
# @param attribute [Cattri::Attribute]
|
|
84
|
+
# @param instance [Object, nil] the runtime instance, if needed for instance-level access
|
|
85
|
+
# @return [Object] the receiver for attribute value storage
|
|
86
|
+
# @raise [Cattri::Error] if instance is required but missing
|
|
87
|
+
def storage_receiver_for: (Attribute attribute, ::Object? instance) -> ::Object
|
|
88
|
+
|
|
71
89
|
private
|
|
72
90
|
|
|
73
91
|
# Internal tracking of explicitly defined methods per attribute.
|
|
@@ -75,11 +93,24 @@ module Cattri
|
|
|
75
93
|
# @return [Hash{Symbol => Set<Symbol>}]
|
|
76
94
|
def __cattri_defined_methods: () -> ::Hash[::Symbol, ::Set[::Symbol]]
|
|
77
95
|
|
|
78
|
-
# Determines whether
|
|
96
|
+
# Determines whether the object is a singleton_class or not.
|
|
97
|
+
#
|
|
98
|
+
# @param obj [Object]
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def singleton_class?: (untyped obj) -> bool
|
|
101
|
+
|
|
102
|
+
# Resolves the proper class-level storage receiver for the attribute.
|
|
79
103
|
#
|
|
80
104
|
# @param attribute [Cattri::Attribute]
|
|
81
|
-
# @
|
|
82
|
-
|
|
105
|
+
# @param instance [Object, nil]
|
|
106
|
+
# @return [Object]
|
|
107
|
+
def resolve_class_storage_for: (Attribute attribute, ::Object? instance) -> ::Object
|
|
108
|
+
|
|
109
|
+
# Installs the internal store on the receiver if not present.
|
|
110
|
+
#
|
|
111
|
+
# @param receiver [Object]
|
|
112
|
+
# @return [void]
|
|
113
|
+
def install_internal_store!: (::Object receiver) -> void
|
|
83
114
|
|
|
84
115
|
# Defines the method and applies its access visibility.
|
|
85
116
|
#
|
|
@@ -13,6 +13,15 @@ module Cattri
|
|
|
13
13
|
|
|
14
14
|
@__cattri_set_variables: ::Set[::Symbol]
|
|
15
15
|
|
|
16
|
+
# Returns the list of attribute keys stored by Cattri on this object.
|
|
17
|
+
#
|
|
18
|
+
# Mimics Ruby's `#instance_variables`, but only includes attributes defined
|
|
19
|
+
# via `cattri` and omits the leading `@` from names. All keys are returned as
|
|
20
|
+
# frozen symbols (e.g., `:enabled` instead of `:@enabled`).
|
|
21
|
+
#
|
|
22
|
+
# @return [Array<Symbol>] the list of internally tracked attribute keys
|
|
23
|
+
def cattri_variables: () -> ::Array[::Symbol]
|
|
24
|
+
|
|
16
25
|
# Checks whether the internal store contains a value for the given key.
|
|
17
26
|
#
|
|
18
27
|
# @param key [String, Symbol] the attribute name or instance variable
|
|
@@ -26,7 +26,7 @@ RSpec.describe Cattri::AttributeCompiler do
|
|
|
26
26
|
|
|
27
27
|
it "eagerly sets the default value on the target class" do
|
|
28
28
|
described_class.define_accessor(attribute, context)
|
|
29
|
-
expect(dummy_class.
|
|
29
|
+
expect(dummy_class.count).to eq(100)
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -67,6 +67,26 @@ RSpec.describe Cattri::AttributeCompiler do
|
|
|
67
67
|
expect(instance.enabled?).to eq(true)
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
|
+
|
|
71
|
+
context "when expose is :read" do
|
|
72
|
+
let(:attribute) do
|
|
73
|
+
Cattri::Attribute.new(
|
|
74
|
+
:visible,
|
|
75
|
+
defined_in: dummy_class,
|
|
76
|
+
default: -> { "shown" },
|
|
77
|
+
expose: :read
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "does not define a writer" do
|
|
82
|
+
described_class.define_accessor(attribute, context)
|
|
83
|
+
instance = dummy_class.new
|
|
84
|
+
|
|
85
|
+
expect(instance.visible).to eq("shown")
|
|
86
|
+
expect(instance.respond_to?(:visible=)).to be(false)
|
|
87
|
+
expect(instance.respond_to?(:visible=, true)).to be(false)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
70
90
|
end
|
|
71
91
|
|
|
72
92
|
describe ".define_accessor!" do
|
|
@@ -134,46 +154,16 @@ RSpec.describe Cattri::AttributeCompiler do
|
|
|
134
154
|
describe ".memoize_default_value" do
|
|
135
155
|
let(:instance) { dummy_class.new }
|
|
136
156
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
it "stores and returns the evaluated default" do
|
|
148
|
-
result = described_class.send(:memoize_default_value, instance, attribute)
|
|
149
|
-
expect(result).to eq("bar")
|
|
150
|
-
expect(instance.cattri_variable_get(:foo)).to eq("bar")
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
context "final attribute" do
|
|
155
|
-
let(:attribute) do
|
|
156
|
-
Cattri::Attribute.new(
|
|
157
|
-
:immutable,
|
|
158
|
-
defined_in: dummy_class,
|
|
159
|
-
final: true,
|
|
160
|
-
default: -> { "locked" },
|
|
161
|
-
expose: :read
|
|
162
|
-
)
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
it "raises if value is not already set" do
|
|
166
|
-
expect do
|
|
167
|
-
described_class.send(:memoize_default_value, instance, attribute)
|
|
168
|
-
end.to raise_error(Cattri::AttributeError, /Final attribute :immutable cannot be written to/)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
it "returns value if already set" do
|
|
172
|
-
instance.cattri_variable_set(:immutable, "preset", final: true)
|
|
173
|
-
result = described_class.send(:memoize_default_value, instance, attribute)
|
|
157
|
+
it "memoizes the evaluated default" do
|
|
158
|
+
attribute = Cattri::Attribute.new(
|
|
159
|
+
:foo,
|
|
160
|
+
defined_in: dummy_class,
|
|
161
|
+
default: -> { "bar" },
|
|
162
|
+
expose: :read_write
|
|
163
|
+
)
|
|
174
164
|
|
|
175
|
-
|
|
176
|
-
|
|
165
|
+
expect(described_class.send(:memoize_default_value, instance, attribute)).to eq("bar")
|
|
166
|
+
expect(instance.cattri_variable_get(:foo)).to eq("bar")
|
|
177
167
|
end
|
|
178
168
|
end
|
|
179
169
|
end
|
|
@@ -144,6 +144,18 @@ RSpec.describe Cattri::AttributeOptions do
|
|
|
144
144
|
expect(instance.instance_variable_get(:@visibility)).to eq(:private)
|
|
145
145
|
end
|
|
146
146
|
end
|
|
147
|
+
|
|
148
|
+
it "raises a helpful error when expose is invalid" do
|
|
149
|
+
expect do
|
|
150
|
+
described_class.new(name, expose: :bogus)
|
|
151
|
+
end.to raise_error(Cattri::AttributeError, /:bogus.*for :my_attribute/)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "raises a helpful error when visibility is invalid" do
|
|
155
|
+
expect do
|
|
156
|
+
described_class.new(name, visibility: :bogus)
|
|
157
|
+
end.to raise_error(Cattri::AttributeError, /:bogus.*for :my_attribute/)
|
|
158
|
+
end
|
|
147
159
|
end
|
|
148
160
|
|
|
149
161
|
describe "#[]" do
|
|
@@ -117,43 +117,6 @@ RSpec.describe Cattri::AttributeRegistry do
|
|
|
117
117
|
end
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
describe "#copy_attributes_to" do
|
|
121
|
-
let(:target_klass) do
|
|
122
|
-
Class.new do
|
|
123
|
-
include Cattri
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
let!(:target_registry) { target_klass.send(:attribute_registry) }
|
|
128
|
-
let(:target_context) { Cattri::Context.new(target_klass) }
|
|
129
|
-
|
|
130
|
-
it "copies final class attributes and sets their values" do
|
|
131
|
-
registry.define_attribute(:enabled, "yes", scope: :class, final: true)
|
|
132
|
-
registry.context.target.cattri_variable_set(:@enabled, "yes")
|
|
133
|
-
|
|
134
|
-
registry.copy_attributes_to(target_context)
|
|
135
|
-
|
|
136
|
-
expect(target_klass.cattri_variable_get(:@enabled)).to eq("yes")
|
|
137
|
-
expect(target_registry.fetch_attribute(:enabled)).to be_a(Cattri::Attribute)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
it "skips non-final or instance-level attributes" do
|
|
141
|
-
registry.define_attribute(:skipped1, "val", scope: :instance, final: true)
|
|
142
|
-
registry.define_attribute(:skipped2, "val", scope: :class, final: false)
|
|
143
|
-
|
|
144
|
-
expect do
|
|
145
|
-
registry.copy_attributes_to(target_context)
|
|
146
|
-
end.not_to(change { target_klass.send(:attribute_registry).defined_attributes })
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
it "restores original context after copying" do
|
|
150
|
-
original_context = registry.context
|
|
151
|
-
registry.copy_attributes_to(target_context)
|
|
152
|
-
|
|
153
|
-
expect(registry.context).to equal(original_context)
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
|
|
157
120
|
describe "#validate_unique!" do
|
|
158
121
|
context "when no attributes are registered" do
|
|
159
122
|
it "returns without raising" do
|
|
@@ -112,8 +112,8 @@ RSpec.describe Cattri::Attribute do
|
|
|
112
112
|
describe "#writable?" do
|
|
113
113
|
[
|
|
114
114
|
[true, :read, false],
|
|
115
|
-
[true, :write,
|
|
116
|
-
[true, :read_write,
|
|
115
|
+
[true, :write, true],
|
|
116
|
+
[true, :read_write, true],
|
|
117
117
|
[true, :none, false],
|
|
118
118
|
[false, :read, false],
|
|
119
119
|
[false, :write, true],
|
|
@@ -134,8 +134,8 @@ RSpec.describe Cattri::Attribute do
|
|
|
134
134
|
describe "#readonly?" do
|
|
135
135
|
[
|
|
136
136
|
[true, :read, true],
|
|
137
|
-
[true, :write,
|
|
138
|
-
[true, :read_write,
|
|
137
|
+
[true, :write, false],
|
|
138
|
+
[true, :read_write, false],
|
|
139
139
|
[true, :none, false],
|
|
140
140
|
[false, :read, true],
|
|
141
141
|
[false, :write, false],
|
data/spec/cattri/context_spec.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "spec_helper"
|
|
4
4
|
|
|
5
5
|
RSpec.describe Cattri::Context do
|
|
6
|
-
let(:context_target) { Class.new }
|
|
6
|
+
let!(:context_target) { Class.new }
|
|
7
7
|
|
|
8
8
|
def define_attribute(name, value, scope: :instance, visibility: :public)
|
|
9
9
|
Cattri::Attribute.new(
|
|
@@ -181,6 +181,64 @@ RSpec.describe Cattri::Context do
|
|
|
181
181
|
end
|
|
182
182
|
end
|
|
183
183
|
|
|
184
|
+
describe "#target_for" do
|
|
185
|
+
subject(:result) { context.target_for(attribute) }
|
|
186
|
+
|
|
187
|
+
context "when a class-level attribute is provided" do
|
|
188
|
+
let(:attribute) { class_attribute }
|
|
189
|
+
|
|
190
|
+
it "returns the singleton_class" do
|
|
191
|
+
expect(result).to eq(context_target.singleton_class)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
context "when a instance-level attribute is provided" do
|
|
196
|
+
let(:attribute) { instance_attribute }
|
|
197
|
+
|
|
198
|
+
it "returns the context's target" do
|
|
199
|
+
expect(result).to eq(context_target)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
context "when a class-level attribute is provided and the context is a singleton_class" do
|
|
204
|
+
let(:context_target) { singleton_class }
|
|
205
|
+
let(:attribute) { class_attribute }
|
|
206
|
+
|
|
207
|
+
it "returns the singleton_class" do
|
|
208
|
+
expect(result).to eq(singleton_class)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
describe "#storage_receiver_for" do
|
|
214
|
+
context "when a class-level attribute is provided" do
|
|
215
|
+
it "resolves to the instance's singleton_class, if instance is provided" do
|
|
216
|
+
receiver = context.storage_receiver_for(class_attribute, self)
|
|
217
|
+
expect(receiver).to eq(singleton_class)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it "resolves to the attribute's defined_in.singleton_class when no instance is provided" do
|
|
221
|
+
receiver = context.storage_receiver_for(class_attribute) # no instance passed
|
|
222
|
+
expect(receiver).to eq(context_target.singleton_class)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
context "when a instance-level attribute is provided" do
|
|
227
|
+
it "returns the instance" do
|
|
228
|
+
receiver = context.storage_receiver_for(instance_attribute, self)
|
|
229
|
+
expect(receiver).to eq(self)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
context "when a received cannot be resolved" do
|
|
234
|
+
it "raises a descriptive Cattri::Error" do
|
|
235
|
+
expect { context.storage_receiver_for(instance_attribute, nil) }
|
|
236
|
+
.to raise_error(Cattri::Error,
|
|
237
|
+
/Missing runtime instance for instance-level attribute :#{instance_attribute.name}/)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
184
242
|
describe "#__cattri_defined_methods" do
|
|
185
243
|
it "returns a Hash" do
|
|
186
244
|
expect(context.send(:__cattri_defined_methods)).to be_a(Hash)
|
|
@@ -201,26 +259,85 @@ RSpec.describe Cattri::Context do
|
|
|
201
259
|
end
|
|
202
260
|
end
|
|
203
261
|
|
|
204
|
-
describe "#
|
|
205
|
-
|
|
262
|
+
describe "#resolve_class_storage_for" do
|
|
263
|
+
def define_attribute(name, final:, defined_in:, scope: :class)
|
|
264
|
+
Cattri::Attribute.new(
|
|
265
|
+
name,
|
|
266
|
+
final: final,
|
|
267
|
+
defined_in: defined_in,
|
|
268
|
+
scope: scope,
|
|
269
|
+
default: -> { "value" },
|
|
270
|
+
visibility: :public
|
|
271
|
+
)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
context "when attribute is final" do
|
|
275
|
+
it "returns defined_in if already a singleton_class" do
|
|
276
|
+
mod = Module.new
|
|
277
|
+
singleton = mod.singleton_class
|
|
278
|
+
attribute = define_attribute(:final_attr, final: true, defined_in: singleton)
|
|
279
|
+
|
|
280
|
+
expect(context.send(:resolve_class_storage_for, attribute, nil)).to eq(singleton)
|
|
281
|
+
end
|
|
206
282
|
|
|
207
|
-
|
|
208
|
-
|
|
283
|
+
it "returns singleton_class of defined_in if not already a singleton" do
|
|
284
|
+
klass = Class.new
|
|
285
|
+
attribute = define_attribute(:final_attr, final: true, defined_in: klass)
|
|
209
286
|
|
|
210
|
-
|
|
211
|
-
expect(result).to eq(context_target.singleton_class)
|
|
287
|
+
expect(context.send(:resolve_class_storage_for, attribute, nil)).to eq(klass.singleton_class)
|
|
212
288
|
end
|
|
213
289
|
end
|
|
214
290
|
|
|
215
|
-
context "when
|
|
216
|
-
|
|
291
|
+
context "when attribute is not final" do
|
|
292
|
+
it "returns singleton_class of instance if instance is provided" do
|
|
293
|
+
instance = Object.new
|
|
294
|
+
attribute = define_attribute(:nonfinal_attr, final: false, defined_in: context_target)
|
|
217
295
|
|
|
218
|
-
|
|
219
|
-
|
|
296
|
+
expect(context.send(:resolve_class_storage_for, attribute, instance)).to eq(instance.singleton_class)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it "returns singleton_class of defined_in if no instance is provided" do
|
|
300
|
+
attribute = define_attribute(:nonfinal_attr, final: false, defined_in: context_target)
|
|
301
|
+
|
|
302
|
+
expect(context.send(:resolve_class_storage_for, attribute, nil)).to eq(context_target.singleton_class)
|
|
220
303
|
end
|
|
221
304
|
end
|
|
222
305
|
end
|
|
223
306
|
|
|
307
|
+
describe "#install_internal_store!" do
|
|
308
|
+
let(:context) { Cattri::Context.new(Object.new) }
|
|
309
|
+
|
|
310
|
+
it "does nothing if receiver already responds to cattri_variables" do
|
|
311
|
+
receiver = Class.new do
|
|
312
|
+
def self.cattri_variables; end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
expect { context.send(:install_internal_store!, receiver) }
|
|
316
|
+
.not_to(change { receiver.ancestors })
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
it "extends singleton class if receiver is a singleton class" do
|
|
320
|
+
base = Class.new
|
|
321
|
+
singleton = base.singleton_class
|
|
322
|
+
|
|
323
|
+
expect(singleton.ancestors).not_to include(Cattri::InternalStore)
|
|
324
|
+
|
|
325
|
+
context.send(:install_internal_store!, singleton)
|
|
326
|
+
|
|
327
|
+
expect(singleton.ancestors).to include(Cattri::InternalStore)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it "includes module if receiver is a regular class or module" do
|
|
331
|
+
klass = Class.new
|
|
332
|
+
|
|
333
|
+
expect(klass.ancestors).not_to include(Cattri::InternalStore)
|
|
334
|
+
|
|
335
|
+
context.send(:install_internal_store!, klass)
|
|
336
|
+
|
|
337
|
+
expect(klass.ancestors).to include(Cattri::InternalStore)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
224
341
|
describe "#define_method!" do
|
|
225
342
|
let(:target) { context.send(:target_for, class_attribute) }
|
|
226
343
|
let(:attribute_name) { class_attribute.name }
|
|
@@ -13,6 +13,16 @@ RSpec.describe Cattri::InternalStore do
|
|
|
13
13
|
|
|
14
14
|
subject(:instance) { klass.new }
|
|
15
15
|
|
|
16
|
+
describe "#cattri_variables" do
|
|
17
|
+
it "returns a list of all cattri variables" do
|
|
18
|
+
instance.cattri_variable_set(:foo, 123)
|
|
19
|
+
instance.cattri_variable_set(:bar, 123)
|
|
20
|
+
instance.cattri_variable_set(:@baz, 123)
|
|
21
|
+
|
|
22
|
+
expect(instance.cattri_variables).to match_array(%i[foo bar baz])
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
16
26
|
describe "#cattri_variable_defined?" do
|
|
17
27
|
it "returns false when variable is not set" do
|
|
18
28
|
expect(instance.cattri_variable_defined?(:foo)).to be false
|
|
@@ -6,6 +6,7 @@ RSpec.describe Cattri::Introspection do
|
|
|
6
6
|
let(:klass) do
|
|
7
7
|
Class.new do
|
|
8
8
|
include Cattri
|
|
9
|
+
|
|
9
10
|
cattri :foo, 123
|
|
10
11
|
end
|
|
11
12
|
end
|
|
@@ -13,6 +14,7 @@ RSpec.describe Cattri::Introspection do
|
|
|
13
14
|
let(:subclass) do
|
|
14
15
|
Class.new(klass) do
|
|
15
16
|
include Cattri::Introspection
|
|
17
|
+
|
|
16
18
|
cattri :bar, "bar"
|
|
17
19
|
end
|
|
18
20
|
end
|
|
@@ -75,6 +77,12 @@ RSpec.describe Cattri::Introspection do
|
|
|
75
77
|
expect(methods.keys).to include(:bar)
|
|
76
78
|
expect(methods[:bar]).to include(:bar)
|
|
77
79
|
end
|
|
80
|
+
|
|
81
|
+
it "includes methods for inherited attributes" do
|
|
82
|
+
methods = subclass.attribute_methods
|
|
83
|
+
expect(methods.keys).to include(:foo)
|
|
84
|
+
expect(methods[:foo]).to include(:foo)
|
|
85
|
+
end
|
|
78
86
|
end
|
|
79
87
|
|
|
80
88
|
describe ".attribute_source" do
|
data/spec/cattri_spec.rb
CHANGED
|
@@ -35,6 +35,7 @@ RSpec.describe Cattri do
|
|
|
35
35
|
let(:introspective_class) do
|
|
36
36
|
Class.new do
|
|
37
37
|
include Cattri
|
|
38
|
+
|
|
38
39
|
cattri :id, "foo"
|
|
39
40
|
|
|
40
41
|
with_cattri_introspection
|
|
@@ -51,4 +52,190 @@ RSpec.describe Cattri do
|
|
|
51
52
|
expect(introspective_class.attribute_methods).to include(:id)
|
|
52
53
|
end
|
|
53
54
|
end
|
|
55
|
+
|
|
56
|
+
describe "regression tests" do
|
|
57
|
+
it "allows one-time assignment to final instance attribute" do
|
|
58
|
+
klass = Class.new do
|
|
59
|
+
include Cattri
|
|
60
|
+
|
|
61
|
+
cattri :value, final: true
|
|
62
|
+
|
|
63
|
+
def initialize(value)
|
|
64
|
+
self.value = value
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
instance = klass.new("first")
|
|
69
|
+
expect(instance.value).to eq("first")
|
|
70
|
+
|
|
71
|
+
expect { instance.value = "second" }.to raise_error(Cattri::AttributeError)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "prevents any assignment to final class attribute" do
|
|
75
|
+
klass = Class.new do
|
|
76
|
+
include Cattri
|
|
77
|
+
|
|
78
|
+
cattri :value, -> { "init" }, final: true, scope: :class
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
expect(klass.value).to eq("init")
|
|
82
|
+
expect { klass.value = "fail" }.to raise_error(Cattri::AttributeError)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "allows shadowing parent class attributes" do
|
|
86
|
+
parent = Class.new do
|
|
87
|
+
include Cattri
|
|
88
|
+
|
|
89
|
+
cattri :enabled, true, final: true, scope: :class
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
child = Class.new do
|
|
93
|
+
include Cattri
|
|
94
|
+
|
|
95
|
+
cattri :enabled, false, final: true, scope: :class
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
expect(parent.enabled).to be(true)
|
|
99
|
+
expect(child.enabled).to be(false)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "defines predicate method for instance attribute" do
|
|
103
|
+
klass = Class.new do
|
|
104
|
+
include Cattri
|
|
105
|
+
|
|
106
|
+
cattri :flag, false, predicate: true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
instance = klass.new
|
|
110
|
+
expect(instance.flag?).to eq(false)
|
|
111
|
+
|
|
112
|
+
instance.flag = true
|
|
113
|
+
expect(instance.flag?).to eq(true)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "evaluates and stores default value lazily" do
|
|
117
|
+
klass = Class.new do
|
|
118
|
+
include Cattri
|
|
119
|
+
|
|
120
|
+
cattri :computed, -> { "value" }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
instance = klass.new
|
|
124
|
+
|
|
125
|
+
expect(instance.cattri_variable_defined?(:computed)).to eq(false)
|
|
126
|
+
expect(instance.computed).to eq("value")
|
|
127
|
+
expect(instance.cattri_variable_defined?(:computed)).to eq(true)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "isolates instance and class attributes correctly" do
|
|
131
|
+
klass = Class.new do
|
|
132
|
+
include Cattri
|
|
133
|
+
|
|
134
|
+
cattri :config, scope: :class
|
|
135
|
+
cattri :state, scope: :instance
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
klass.config = "shared"
|
|
139
|
+
a = klass.new
|
|
140
|
+
b = klass.new
|
|
141
|
+
a.state = "one"
|
|
142
|
+
b.state = "two"
|
|
143
|
+
|
|
144
|
+
expect(klass.config).to eq("shared")
|
|
145
|
+
expect(a.state).to eq("one")
|
|
146
|
+
expect(b.state).to eq("two")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "allows for class attributes set on a module's singleton_class" do
|
|
150
|
+
mod = Module.new do
|
|
151
|
+
class << self
|
|
152
|
+
include Cattri
|
|
153
|
+
|
|
154
|
+
cattri :version, "0.1.0", final: true, scope: :class
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
expect(mod.version).to eq("0.1.0"), "Expected module singleton_class to retain class-level attribute value"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "isolates class attributes across subclasses" do
|
|
162
|
+
parent = Class.new do
|
|
163
|
+
include Cattri
|
|
164
|
+
|
|
165
|
+
cattri :level, "parent", scope: :class
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
child = Class.new(parent)
|
|
169
|
+
expect(child.level).to eq("parent")
|
|
170
|
+
|
|
171
|
+
child.level = "child"
|
|
172
|
+
|
|
173
|
+
expect(parent.level).to eq("parent")
|
|
174
|
+
expect(child.level).to eq("child")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it "applies custom coercion via block during assignment" do
|
|
178
|
+
klass = Class.new do
|
|
179
|
+
include Cattri
|
|
180
|
+
|
|
181
|
+
cattri :age do |value|
|
|
182
|
+
Integer(value)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
instance = klass.new
|
|
187
|
+
instance.age = "42"
|
|
188
|
+
|
|
189
|
+
expect(instance.age).to eq(42)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "allows inherited initialize to set final attribute once" do
|
|
193
|
+
base = Class.new do
|
|
194
|
+
include Cattri
|
|
195
|
+
|
|
196
|
+
cattri :token, final: true
|
|
197
|
+
|
|
198
|
+
def initialize(token)
|
|
199
|
+
self.token = token
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
subclass = Class.new(base)
|
|
204
|
+
obj = subclass.new("abc123")
|
|
205
|
+
|
|
206
|
+
expect(obj.token).to eq("abc123")
|
|
207
|
+
expect { obj.token = "fail" }.to raise_error(Cattri::AttributeError)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
describe "visibility with write-only exposure" do
|
|
212
|
+
let(:klass) do
|
|
213
|
+
Class.new do
|
|
214
|
+
include Cattri
|
|
215
|
+
|
|
216
|
+
cattri :token, expose: :write
|
|
217
|
+
cattri :level, :low, expose: :write, scope: :class
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it "keeps the reader private while writer stays public on instances" do
|
|
222
|
+
instance = klass.new
|
|
223
|
+
|
|
224
|
+
expect(klass.public_instance_methods(false)).to include(:token=)
|
|
225
|
+
expect(klass.private_instance_methods(false)).to include(:token)
|
|
226
|
+
expect { instance.token }.to raise_error(NoMethodError)
|
|
227
|
+
|
|
228
|
+
instance.token = "set"
|
|
229
|
+
expect(instance.send(:token)).to eq("set")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it "marks class-level readers as protected while keeping the writer public" do
|
|
233
|
+
expect(klass.singleton_class.public_instance_methods(false)).to include(:level=)
|
|
234
|
+
expect(klass.singleton_class.protected_instance_methods(false)).to include(:level)
|
|
235
|
+
expect { klass.level }.to raise_error(NoMethodError)
|
|
236
|
+
|
|
237
|
+
klass.level = :high
|
|
238
|
+
expect(klass.send(:level)).to eq(:high)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
54
241
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cattri
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nathan Lucas
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-
|
|
11
|
+
date: 2025-12-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: debride
|
|
@@ -154,7 +154,6 @@ files:
|
|
|
154
154
|
- lib/cattri/deferred_attributes.rb
|
|
155
155
|
- lib/cattri/dsl.rb
|
|
156
156
|
- lib/cattri/error.rb
|
|
157
|
-
- lib/cattri/inheritance.rb
|
|
158
157
|
- lib/cattri/initializer_patch.rb
|
|
159
158
|
- lib/cattri/internal_store.rb
|
|
160
159
|
- lib/cattri/introspection.rb
|
|
@@ -170,7 +169,6 @@ files:
|
|
|
170
169
|
- sig/lib/cattri/deferred_attributes.rbs
|
|
171
170
|
- sig/lib/cattri/dsl.rbs
|
|
172
171
|
- sig/lib/cattri/error.rbs
|
|
173
|
-
- sig/lib/cattri/inheritance.rbs
|
|
174
172
|
- sig/lib/cattri/initializer_patch.rbs
|
|
175
173
|
- sig/lib/cattri/internal_store.rbs
|
|
176
174
|
- sig/lib/cattri/introspection.rbs
|
|
@@ -185,7 +183,6 @@ files:
|
|
|
185
183
|
- spec/cattri/deferred_attrributes_spec.rb
|
|
186
184
|
- spec/cattri/dsl_spec.rb
|
|
187
185
|
- spec/cattri/error_spec.rb
|
|
188
|
-
- spec/cattri/inheritance_spec.rb
|
|
189
186
|
- spec/cattri/initializer_patch_spec.rb
|
|
190
187
|
- spec/cattri/internal_store_spec.rb
|
|
191
188
|
- spec/cattri/introspection_spec.rb
|
data/lib/cattri/inheritance.rb
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Cattri
|
|
4
|
-
# Handles subclassing behavior for classes that use Cattri.
|
|
5
|
-
#
|
|
6
|
-
# This module installs a custom `.inherited` hook on the target class's singleton,
|
|
7
|
-
# ensuring that attribute definitions and values are deep-copied to the subclass.
|
|
8
|
-
#
|
|
9
|
-
# The hook preserves any existing `.inherited` behavior defined on the class,
|
|
10
|
-
# calling it before applying attribute propagation.
|
|
11
|
-
module Inheritance
|
|
12
|
-
# Installs an `inherited` hook on the given class.
|
|
13
|
-
#
|
|
14
|
-
# When the class is subclassed, Cattri will copy over attribute metadata and values
|
|
15
|
-
# using the subclass’s context. This ensures subclass safety and definition isolation.
|
|
16
|
-
#
|
|
17
|
-
# Any pre-existing `.inherited` method is preserved and invoked first.
|
|
18
|
-
#
|
|
19
|
-
# @param base [Class] the class to install the hook on
|
|
20
|
-
# @return [void]
|
|
21
|
-
def self.install(base)
|
|
22
|
-
singleton = base.singleton_class
|
|
23
|
-
existing = singleton.instance_method(:inherited) rescue nil # rubocop:disable Style/RescueModifier
|
|
24
|
-
|
|
25
|
-
singleton.define_method(:inherited) do |subclass|
|
|
26
|
-
# :nocov:
|
|
27
|
-
existing.bind(self).call(subclass) # steep:ignore
|
|
28
|
-
# :nocov:
|
|
29
|
-
|
|
30
|
-
context = Cattri::Context.new(subclass)
|
|
31
|
-
attribute_registry.send(:copy_attributes_to, context) # steep:ignore
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
module Cattri
|
|
2
|
-
# Handles subclassing behavior for classes that use Cattri.
|
|
3
|
-
#
|
|
4
|
-
# This module installs a custom `.inherited` hook on the target class's singleton,
|
|
5
|
-
# ensuring that attribute definitions and values are deep-copied to the subclass.
|
|
6
|
-
#
|
|
7
|
-
# The hook preserves any existing `.inherited` behavior defined on the class,
|
|
8
|
-
# calling it before applying attribute propagation.
|
|
9
|
-
module Inheritance
|
|
10
|
-
# Installs an `inherited` hook on the given class.
|
|
11
|
-
#
|
|
12
|
-
# When the class is subclassed, Cattri will copy over attribute metadata and values
|
|
13
|
-
# using the subclass’s context. This ensures subclass safety and definition isolation.
|
|
14
|
-
#
|
|
15
|
-
# Any pre-existing `.inherited` method is preserved and invoked first.
|
|
16
|
-
#
|
|
17
|
-
# @param base [Class] the class to install the hook on
|
|
18
|
-
# @return [void]
|
|
19
|
-
def self.install: (::Module base) -> void
|
|
20
|
-
end
|
|
21
|
-
end
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "spec_helper"
|
|
4
|
-
|
|
5
|
-
RSpec.describe Cattri::Inheritance do
|
|
6
|
-
let(:base_class) do
|
|
7
|
-
Class.new.tap do |klass|
|
|
8
|
-
klass.include(Cattri::ContextRegistry)
|
|
9
|
-
klass.singleton_class.include(Cattri::ContextRegistry)
|
|
10
|
-
|
|
11
|
-
klass.attr_reader :inherited_called_with
|
|
12
|
-
|
|
13
|
-
def klass.inherited(subclass)
|
|
14
|
-
@inherited_called_with = subclass
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def klass.inherited_called_with # rubocop:disable Style/TrivialAccessors
|
|
18
|
-
@inherited_called_with
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
before do
|
|
24
|
-
allow(Cattri::Context).to receive(:new).and_call_original
|
|
25
|
-
allow_any_instance_of(Cattri::AttributeRegistry).to receive(:copy_attributes_to)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
describe ".install" do
|
|
29
|
-
it "preserves and calls existing inherited method" do
|
|
30
|
-
Cattri::Inheritance.install(base_class)
|
|
31
|
-
|
|
32
|
-
subclass = Class.new(base_class) # triggers inherited hook
|
|
33
|
-
|
|
34
|
-
expect(base_class.inherited_called_with).to eq(subclass)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
it "initializes a Cattri::Context for the subclass" do
|
|
38
|
-
Cattri::Inheritance.install(base_class)
|
|
39
|
-
|
|
40
|
-
expect(Cattri::Context).to receive(:new) do |actual_subclass|
|
|
41
|
-
expect(actual_subclass.superclass).to eq(base_class)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
Class.new(base_class)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
it "invokes copy_attributes_to with the subclass context" do
|
|
48
|
-
context = instance_double(Cattri::Context)
|
|
49
|
-
allow(Cattri::Context).to receive(:new).and_return(context)
|
|
50
|
-
|
|
51
|
-
registry = instance_double(Cattri::AttributeRegistry)
|
|
52
|
-
allow(base_class).to receive(:attribute_registry).and_return(registry)
|
|
53
|
-
|
|
54
|
-
expect(registry).to receive(:copy_attributes_to).with(context)
|
|
55
|
-
|
|
56
|
-
Cattri::Inheritance.install(base_class)
|
|
57
|
-
Class.new(base_class)
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|