cattri 0.1.0 → 0.1.1
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/.rubocop.yml +5 -1
- data/CHANGELOG.md +31 -1
- data/README.md +141 -85
- data/lib/cattri/attribute.rb +202 -0
- data/lib/cattri/attribute_definer.rb +112 -0
- data/lib/cattri/class_attributes.rb +60 -171
- data/lib/cattri/context.rb +155 -0
- data/lib/cattri/error.rb +67 -0
- data/lib/cattri/instance_attributes.rb +62 -110
- data/lib/cattri/introspection.rb +1 -1
- data/lib/cattri/version.rb +1 -1
- data/lib/cattri/visibility.rb +66 -0
- data/lib/cattri.rb +91 -8
- metadata +6 -5
- data/.idea/workspace.xml +0 -350
- data/.rspec_status +0 -75
- data/lib/cattri/helpers.rb +0 -75
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e8689b6e0fa1e741fc271fece58183191d290875fe203af5622f069aaf3c4cf7
|
4
|
+
data.tar.gz: 2435ba9915a003f39d2d0275c725064c0c5c885940e7de109bb852a7258dfdd6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 847f1396957fa8567868273f61c1906e95001e0fd8c632de72ed3f520ec4a7edb1c91c7c1f6a02101913c392d119b24a32a4d835827dab864338ab0e6b668574
|
7
|
+
data.tar.gz: f7347d6cb14bb1b0501ff45799ba513388462ede2c95a47f84efb9d4a99ce7c9a6c6a8c726e605b4d475b26664c5f8861f8fc50922da4ebfbe4b139a257af131
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,4 +1,34 @@
|
|
1
|
-
## [
|
1
|
+
## [0.1.1] - 2025-04-22
|
2
|
+
|
3
|
+
### Added
|
4
|
+
|
5
|
+
- `Cattri::Context`: new class encapsulating all class-level and instance-level attribute metadata.
|
6
|
+
- `Cattri::Attribute`: formal representation of a defined attribute, with support for default values, coercion, and visibility.
|
7
|
+
- `Cattri::AttributeDefiner`: internal abstraction for building attributes and assigning behavior based on visibility and type.
|
8
|
+
- `Cattri::Visibility`: tracks current method visibility (`public`, `protected`, `private`) to ensure dynamically defined methods (e.g., via `cattr`, `iattr`) respect the active access scope during declaration.
|
9
|
+
|
10
|
+
### Changed
|
11
|
+
|
12
|
+
- Internal architecture now uses `Context` to manage attribute storage and duplication logic across inheritance chains.
|
13
|
+
- Class and instance attribute definitions (`cattr`, `iattr`) now delegate to `AttributeDefiner`, improving consistency and reducing duplication.
|
14
|
+
- Visibility handling is now centralized through `Cattri::Visibility`, which intercepts `public`, `protected`, and `private` to track and apply the current access level when defining methods.
|
15
|
+
- Subclass inheritance now copies attribute metadata and current values using a consistent, visibility-aware strategy.
|
16
|
+
|
17
|
+
### Removed
|
18
|
+
|
19
|
+
- Legacy handling of attribute hashes and manual copying in `inherited` hooks.
|
20
|
+
- Ad hoc attribute construction logic in `ClassAttributes` and `InstanceAttributes`.
|
21
|
+
|
22
|
+
### Improved
|
23
|
+
|
24
|
+
- Clear separation of concerns between metadata (`Attribute`), context (`Context`), and definition logic (`AttributeDefiner`).
|
25
|
+
- More robust error messages and consistent failure behavior when defining attributes with invalid configuration.
|
26
|
+
|
27
|
+
---
|
28
|
+
|
29
|
+
No breaking changes – the public DSL (cattr, iattr) remains identical to v0.1.0.
|
30
|
+
|
31
|
+
---
|
2
32
|
|
3
33
|
## [0.1.0] - 2025-04-17
|
4
34
|
|
data/README.md
CHANGED
@@ -1,153 +1,209 @@
|
|
1
1
|
# Cattri
|
2
2
|
|
3
|
-
|
3
|
+
A **minimal‑footprint** DSL for defining **class‑level** and **instance‑level** attributes in Ruby, with first‑class support for custom defaults, coercion, visibility tracking, and safety‑first error handling.
|
4
4
|
|
5
|
-
|
5
|
+
---
|
6
|
+
|
7
|
+
## Why another attribute DSL?
|
6
8
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
9
|
+
| Capability | Cattri | `attr_*` / `cattr_*` (Ruby / ActiveSupport) | `dry-configurable` |
|
10
|
+
|------------|:------:|:-------------------------------------------:|:------------------:|
|
11
|
+
| **Single DSL for class *and* instance attributes** | ✅ | ❌ (separate APIs) | ⚠️ (config only) |
|
12
|
+
| **Per‑subclass deep copy of attribute metadata & values** | ✅ | ❌ | ❌ |
|
13
|
+
| **Built‑in visibility tracking (`public` / `protected` / `private`)** | ✅ | ❌ | ❌ |
|
14
|
+
| **Lazy or static *default* values** | ✅ | ⚠️ (writer‑based) | ✅ |
|
15
|
+
| **Optional *coercion* via custom setter block** | ✅ | ❌ | ✅ |
|
16
|
+
| **Read‑only / write‑only flags** | ✅ | ⚠️ (reader / writer macros) | ❌ |
|
17
|
+
| **Introspection helpers (`snapshot_*`)** | ✅ | ❌ | ⚠️ via internals |
|
18
|
+
| **Clear, granular error hierarchy** | ✅ | ❌ | ✅ |
|
19
|
+
| **Zero runtime dependencies** | ✅ | ⚠️ (ActiveSupport) | ✅ |
|
11
20
|
|
12
|
-
|
21
|
+
> **TL;DR** – If you need lightweight, _Rails‑agnostic_ attribute helpers that play nicely with inheritance and don’t leak state between subclasses, Cattri is for you.
|
13
22
|
|
14
|
-
|
15
|
-
- 🧱 Static or callable default values
|
16
|
-
- 🌀 Optional coercion logic via blocks
|
17
|
-
- 🧼 Reset attributes to default
|
18
|
-
- 🔒 Lock class attribute definitions in base class
|
19
|
-
- 🔍 Introspect attribute definitions and values (optional)
|
23
|
+
---
|
20
24
|
|
21
|
-
##
|
25
|
+
## Installation
|
22
26
|
|
23
27
|
```bash
|
24
|
-
bundle add cattri
|
28
|
+
bundle add cattri # Ruby ≥ 2.7
|
25
29
|
```
|
26
30
|
|
27
|
-
Or
|
31
|
+
Or in your Gemfile:
|
28
32
|
|
29
33
|
```ruby
|
30
34
|
gem "cattri"
|
31
35
|
```
|
32
36
|
|
33
|
-
|
37
|
+
---
|
34
38
|
|
35
|
-
|
39
|
+
## Quick start
|
36
40
|
|
37
41
|
```ruby
|
38
|
-
class
|
39
|
-
include Cattri
|
42
|
+
class Config
|
43
|
+
include Cattri # exposes `cattr` & `iattr`
|
44
|
+
|
45
|
+
# -- class‑level ----------------------------------
|
46
|
+
cattr :enabled, default: true
|
47
|
+
cattr :timeout, default: -> { 5.0 }, instance_reader: false
|
40
48
|
|
41
|
-
|
42
|
-
iattr :name,
|
49
|
+
# -- instance‑level -------------------------------
|
50
|
+
iattr :name, default: "anonymous"
|
51
|
+
iattr :age, default: 0 do |val| # coercion block
|
52
|
+
Integer(val)
|
53
|
+
end
|
43
54
|
end
|
44
55
|
|
45
|
-
|
46
|
-
|
56
|
+
Config.enabled # => true
|
57
|
+
Config.enabled = false
|
58
|
+
Config.new.age = "42" # => 42
|
47
59
|
```
|
48
60
|
|
49
|
-
|
50
|
-
|
51
|
-
```ruby
|
52
|
-
class MyConfig
|
53
|
-
extend Cattri::ClassAttributes
|
61
|
+
---
|
54
62
|
|
55
|
-
|
56
|
-
cattr_reader :version, default: "1.0.0"
|
57
|
-
cattr :enabled, default: true do |value|
|
58
|
-
!!value
|
59
|
-
end
|
60
|
-
end
|
63
|
+
## Defining attributes
|
61
64
|
|
62
|
-
|
63
|
-
MyConfig.format :xml
|
64
|
-
MyConfig.format # => :xml
|
65
|
+
### Class attributes (`cattr`)
|
65
66
|
|
66
|
-
|
67
|
+
```ruby
|
68
|
+
cattr :log_level, default: :info,
|
69
|
+
access: :protected, # respects current visibility by default
|
70
|
+
readonly: false,
|
71
|
+
instance_reader: true do |value|
|
72
|
+
value.to_sym
|
73
|
+
end
|
67
74
|
```
|
68
75
|
|
69
|
-
|
76
|
+
### Instance attributes (`iattr`)
|
70
77
|
|
71
78
|
```ruby
|
72
|
-
|
79
|
+
iattr :token, default: -> { SecureRandom.hex(8) },
|
80
|
+
reader: true,
|
81
|
+
writer: false # read‑only
|
73
82
|
```
|
74
83
|
|
75
|
-
|
84
|
+
Both forms accept:
|
76
85
|
|
77
|
-
|
78
|
-
|
79
|
-
|
86
|
+
| Option | Purpose |
|
87
|
+
| ------ | ------- |
|
88
|
+
| `default:` | Static value or callable (`Proc`) evaluated lazily. |
|
89
|
+
| `access:` | Override inferred visibility (`:public`, `:protected`, `:private`). |
|
90
|
+
| `reader:` / `writer:` | Disable reader or writer for instance attributes. |
|
91
|
+
| `readonly:` | Shorthand for class attributes (`writer` is always present). |
|
92
|
+
| `instance_reader:` | Expose class attribute as instance reader (default: **true**). |
|
93
|
+
|
94
|
+
If you pass a block, it’s treated as a **coercion setter** and receives the incoming value.
|
80
95
|
|
81
|
-
|
96
|
+
---
|
97
|
+
|
98
|
+
## Visibility tracking
|
82
99
|
|
83
|
-
|
100
|
+
Cattri watches calls to `public`, `protected`, and `private` while you define methods:
|
84
101
|
|
85
102
|
```ruby
|
86
|
-
class
|
87
|
-
include Cattri
|
103
|
+
class Secrets
|
104
|
+
include Cattri
|
88
105
|
|
89
|
-
|
90
|
-
|
91
|
-
val.to_s.strip
|
92
|
-
end
|
106
|
+
private
|
107
|
+
cattr :api_key
|
93
108
|
end
|
94
109
|
|
95
|
-
|
96
|
-
req.headers["Content-Type"] = "application/json"
|
97
|
-
req.raw_body = " data "
|
110
|
+
Secrets.private_methods.include?(:api_key) # => true
|
98
111
|
```
|
99
112
|
|
100
|
-
|
113
|
+
No boilerplate—attributes inherit the visibility that was in effect at the call site.
|
114
|
+
|
115
|
+
---
|
116
|
+
|
117
|
+
## Safe inheritance
|
118
|
+
|
119
|
+
Subclassing copies both **metadata** and **current values**, using defensive `#dup` where possible and falling back safely when objects are frozen or not duplicable:
|
101
120
|
|
102
121
|
```ruby
|
103
|
-
|
104
|
-
|
122
|
+
class Base
|
123
|
+
include Cattri
|
124
|
+
cattr :settings, default: {}
|
125
|
+
end
|
126
|
+
|
127
|
+
class Child < Base; end
|
105
128
|
|
106
|
-
|
129
|
+
Base.settings[:foo] = 1
|
130
|
+
Child.settings # => {} (isolated copy)
|
107
131
|
```
|
108
132
|
|
109
|
-
|
133
|
+
---
|
134
|
+
|
135
|
+
## Introspection helpers
|
110
136
|
|
111
|
-
|
137
|
+
Add `include Cattri::Introspection` (or `extend` for class‑only use) to snapshot live values:
|
112
138
|
|
113
139
|
```ruby
|
114
|
-
|
115
|
-
|
116
|
-
|
140
|
+
Config.snapshot_cattrs # => { enabled: false, timeout: 5.0 }
|
141
|
+
instance.snapshot_iattrs # => { name: "bob", age: 42 }
|
142
|
+
```
|
117
143
|
|
118
|
-
|
119
|
-
end
|
144
|
+
Great for debugging or test assertions.
|
120
145
|
|
121
|
-
|
122
|
-
MyConfig.snapshot_class_attributes # => { items: [:a] }
|
123
|
-
```
|
146
|
+
---
|
124
147
|
|
125
|
-
##
|
148
|
+
## Error handling
|
126
149
|
|
127
|
-
|
128
|
-
|----------------------------------|--------------------------------------------|
|
129
|
-
| `cattr`, `cattr_reader` | Define class-level attributes |
|
130
|
-
| `iattr`, `iattr_reader`, `iattr_writer` | Define instance-level attributes |
|
131
|
-
| `reset_cattr!`, `reset_iattr!` | Reset specific attributes |
|
132
|
-
| `cattr_definition(:name)` | Get attribute metadata |
|
133
|
-
| `lock_cattrs!` | Prevent redefinition in subclasses |
|
150
|
+
All errors inherit from `Cattri::Error`, allowing a single rescue for any gem‑specific issue.
|
134
151
|
|
135
|
-
|
152
|
+
| Error class | Raised when… |
|
153
|
+
|-------------|--------------|
|
154
|
+
| `Cattri::AttributeDefinedError` | an attribute is declared twice on the same level |
|
155
|
+
| `Cattri::AttributeDefinitionError` | method generation (`define_method`) fails |
|
156
|
+
| `Cattri::UnsupportedTypeError` | an internal API receives an unknown type |
|
157
|
+
| `Cattri::AttributeError` | generic superclass for attribute‑related issues |
|
136
158
|
|
137
|
-
|
138
|
-
|
159
|
+
Example:
|
160
|
+
|
161
|
+
```ruby
|
162
|
+
begin
|
163
|
+
class Foo
|
164
|
+
include Cattri
|
165
|
+
cattr :foo
|
166
|
+
cattr :foo # duplicate
|
167
|
+
end
|
168
|
+
rescue Cattri::AttributeDefinedError => e
|
169
|
+
warn e.message # => "Class attribute :foo has already been defined"
|
170
|
+
rescue Cattri::AttributeError => e
|
171
|
+
warn e.message # => Catch-all for any error raised within attributes
|
172
|
+
end
|
139
173
|
```
|
140
174
|
|
141
|
-
|
175
|
+
---
|
176
|
+
|
177
|
+
## Comparison with standard patterns
|
178
|
+
|
179
|
+
* **Core Ruby macros** (`attr_accessor`, `cattr_accessor`) are simple but global—attributes bleed into subclasses and lack defaults or coercion.
|
180
|
+
* **ActiveSupport** extends the API but still relies on mutable class variables and offers no visibility control.
|
181
|
+
* **Dry‑configurable** is robust yet heavyweight when you only need a handful of attributes outside a full config object.
|
182
|
+
|
183
|
+
Cattri sits in the sweet spot: **micro‑sized (~200 LOC)**, dependency‑free, and purpose‑built for attribute declaration.
|
184
|
+
|
185
|
+
---
|
186
|
+
|
187
|
+
## Testing tips
|
188
|
+
|
189
|
+
* Use `include Cattri::Introspection` in spec helper files to capture snapshots before/after mutations.
|
190
|
+
* Rescue `Cattri::Error` in high‑level test helpers to assert failures without coupling to sub‑class names.
|
142
191
|
|
143
|
-
|
192
|
+
---
|
144
193
|
|
145
|
-
##
|
194
|
+
## Contributing
|
146
195
|
|
147
|
-
|
196
|
+
1. Fork the repo
|
197
|
+
2. `bundle install`
|
198
|
+
3. Run the test suite with `bundle exec rake`
|
199
|
+
4. Submit a pull request – ensure new code is covered and **rubocop** passes.
|
148
200
|
|
149
201
|
---
|
150
202
|
|
203
|
+
## License
|
204
|
+
|
205
|
+
This gem is released under the MIT License – see See [LICENSE](LICENSE) for details.
|
206
|
+
|
151
207
|
## 🙏 Credits
|
152
208
|
|
153
209
|
Created with ❤️ by [Nathan Lucas](https://github.com/bnlucas)
|
@@ -0,0 +1,202 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "error"
|
4
|
+
|
5
|
+
module Cattri
|
6
|
+
# Represents a single attribute definition in Cattri.
|
7
|
+
#
|
8
|
+
# This class encapsulates metadata and behavior for a declared attribute,
|
9
|
+
# including name, visibility, default value, and setter coercion logic.
|
10
|
+
#
|
11
|
+
# It is used internally by the DSL to configure how accessors are defined,
|
12
|
+
# memoized, and resolved at runtime.
|
13
|
+
class Attribute
|
14
|
+
# Supported attribute scopes within Cattri.
|
15
|
+
ATTRIBUTE_TYPES = %i[class instance].freeze
|
16
|
+
|
17
|
+
# Supported Ruby method visibility levels.
|
18
|
+
ACCESS_LEVELS = %i[public protected private].freeze
|
19
|
+
|
20
|
+
# Ruby value types considered safe to reuse as-is (no `#dup` needed).
|
21
|
+
SAFE_VALUE_TYPES = [Numeric, Symbol, TrueClass, FalseClass, NilClass].freeze
|
22
|
+
|
23
|
+
# Default options for class-level attributes.
|
24
|
+
DEFAULT_CLASS_ATTRIBUTE_OPTIONS = {
|
25
|
+
readonly: false,
|
26
|
+
instance_reader: true
|
27
|
+
}.freeze
|
28
|
+
|
29
|
+
# Default options for instance-level attributes.
|
30
|
+
DEFAULT_INSTANCE_ATTRIBUTE_OPTIONS = {
|
31
|
+
reader: true,
|
32
|
+
writer: true
|
33
|
+
}.freeze
|
34
|
+
|
35
|
+
# @return [Symbol] the attribute name
|
36
|
+
attr_reader :name
|
37
|
+
|
38
|
+
# @return [Symbol] the attribute type (:class or :instance)
|
39
|
+
attr_reader :type
|
40
|
+
|
41
|
+
# @return [Symbol] the associated instance variable (e.g., :@items)
|
42
|
+
attr_reader :ivar
|
43
|
+
|
44
|
+
# @return [Symbol] the access level (:public, :protected, :private)
|
45
|
+
attr_reader :access
|
46
|
+
|
47
|
+
# @return [Proc] the normalized default value block
|
48
|
+
attr_reader :default
|
49
|
+
|
50
|
+
# @return [Proc] the setter function used to assign values
|
51
|
+
attr_reader :setter
|
52
|
+
|
53
|
+
# Initializes a new attribute definition.
|
54
|
+
#
|
55
|
+
# @param name [String, Symbol] the name of the attribute
|
56
|
+
# @param type [Symbol] either :class or :instance
|
57
|
+
# @param options [Hash] additional attribute configuration
|
58
|
+
# @param block [Proc, nil] optional block for setter coercion
|
59
|
+
#
|
60
|
+
# @raise [Cattri::UnsupportedTypeError] if an invalid type is provided
|
61
|
+
def initialize(name, type, options, block)
|
62
|
+
@type = type.to_sym
|
63
|
+
raise Cattri::UnsupportedTypeError, type unless ATTRIBUTE_TYPES.include?(@type)
|
64
|
+
|
65
|
+
@name = name.to_sym
|
66
|
+
@ivar = normalize_ivar(options[:ivar])
|
67
|
+
@access = options[:access] || :public
|
68
|
+
@default = normalize_default(options[:default])
|
69
|
+
@setter = normalize_setter(block)
|
70
|
+
@options = typed_options(options)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Hash-like access to option values or metadata.
|
74
|
+
#
|
75
|
+
# @param key [Symbol, String]
|
76
|
+
# @return [Object]
|
77
|
+
def [](key)
|
78
|
+
to_hash[key.to_sym]
|
79
|
+
end
|
80
|
+
|
81
|
+
# Serializes this attribute to a hash, including core properties and type-specific flags.
|
82
|
+
#
|
83
|
+
# @return [Hash]
|
84
|
+
def to_hash
|
85
|
+
@to_hash ||= {
|
86
|
+
name: @name,
|
87
|
+
ivar: @ivar,
|
88
|
+
type: @type,
|
89
|
+
access: @access,
|
90
|
+
default: @default,
|
91
|
+
setter: @setter
|
92
|
+
}.merge(@options)
|
93
|
+
end
|
94
|
+
|
95
|
+
alias to_h to_hash
|
96
|
+
|
97
|
+
# @return [Boolean] true if the attribute is class-scoped
|
98
|
+
def class_level?
|
99
|
+
type == :class
|
100
|
+
end
|
101
|
+
|
102
|
+
# @return [Boolean] true if the attribute is instance-scoped
|
103
|
+
def instance_level?
|
104
|
+
type == :instance
|
105
|
+
end
|
106
|
+
|
107
|
+
# @return [Boolean] whether the attribute is public
|
108
|
+
def public?
|
109
|
+
access == :public
|
110
|
+
end
|
111
|
+
|
112
|
+
# @return [Boolean] whether the attribute is protected
|
113
|
+
def protected?
|
114
|
+
access == :protected
|
115
|
+
end
|
116
|
+
|
117
|
+
# @return [Boolean] whether the attribute is private
|
118
|
+
def private?
|
119
|
+
access == :private
|
120
|
+
end
|
121
|
+
|
122
|
+
# Invokes the default value logic for the attribute.
|
123
|
+
#
|
124
|
+
# @return [Object] the default value for the attribute
|
125
|
+
# @raise [Cattri::AttributeError] if the default value logic raises an error
|
126
|
+
def invoke_default
|
127
|
+
default.call
|
128
|
+
rescue StandardError => e
|
129
|
+
raise Cattri::AttributeError, "Failed to evaluate the default value for :#{name}. Error: #{e.message}"
|
130
|
+
end
|
131
|
+
|
132
|
+
# Invokes the setter function with error handling
|
133
|
+
#
|
134
|
+
# @param args [Array] the positional arguments
|
135
|
+
# @param kwargs [Hash] the keyword arguments
|
136
|
+
# @raise [Cattri::AttributeError] if setter raises an error
|
137
|
+
# @return [Object] the value returned by the setter
|
138
|
+
def invoke_setter(*args, **kwargs)
|
139
|
+
setter.call(*args, **kwargs)
|
140
|
+
rescue StandardError => e
|
141
|
+
raise Cattri::AttributeError, "Failed to evaluate the setter for :#{name}. Error: #{e.message}"
|
142
|
+
end
|
143
|
+
|
144
|
+
private
|
145
|
+
|
146
|
+
# Applies class- or instance-level defaults and filters valid option keys.
|
147
|
+
#
|
148
|
+
# @param options [Hash]
|
149
|
+
# @return [Hash]
|
150
|
+
def typed_options(options)
|
151
|
+
defaults = type == :class ? DEFAULT_CLASS_ATTRIBUTE_OPTIONS : DEFAULT_INSTANCE_ATTRIBUTE_OPTIONS
|
152
|
+
defaults.merge(options.slice(*defaults.keys))
|
153
|
+
end
|
154
|
+
|
155
|
+
# Normalizes the instance variable name for the attribute.
|
156
|
+
#
|
157
|
+
# @param ivar [String, Symbol, nil]
|
158
|
+
# @return [Symbol]
|
159
|
+
def normalize_ivar(ivar)
|
160
|
+
ivar ||= name
|
161
|
+
:"@#{ivar.to_s.delete_prefix("@")}"
|
162
|
+
end
|
163
|
+
|
164
|
+
# Returns the setter proc. If no block is provided, uses default logic:
|
165
|
+
# - Returns kwargs if given
|
166
|
+
# - Returns the single positional argument if one
|
167
|
+
# - Returns all args as an array otherwise
|
168
|
+
#
|
169
|
+
# @param block [Proc, nil]
|
170
|
+
# @return [Proc]
|
171
|
+
def normalize_setter(block)
|
172
|
+
block || lambda { |*args, **kwargs|
|
173
|
+
return kwargs unless kwargs.empty?
|
174
|
+
return args.first if args.length == 1
|
175
|
+
|
176
|
+
args
|
177
|
+
}
|
178
|
+
end
|
179
|
+
|
180
|
+
# Wraps the default value in a memoized lambda.
|
181
|
+
#
|
182
|
+
# If value is already callable, returns it.
|
183
|
+
# If immutable, wraps it in a lambda.
|
184
|
+
# If mutable, wraps it in a lambda that calls `#dup`.
|
185
|
+
#
|
186
|
+
# @param default [Object, Proc, nil]
|
187
|
+
# @return [Proc]
|
188
|
+
def normalize_default(default)
|
189
|
+
return default if default.respond_to?(:call)
|
190
|
+
return -> { default } if default.frozen? || SAFE_VALUE_TYPES.any? { |type| default.is_a?(type) }
|
191
|
+
|
192
|
+
lambda {
|
193
|
+
begin
|
194
|
+
default.dup
|
195
|
+
rescue StandardError => e
|
196
|
+
raise Cattri::AttributeError,
|
197
|
+
"Failed to duplicate default value for :#{name}. Error: #{e.message}"
|
198
|
+
end
|
199
|
+
}
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cattri
|
4
|
+
# Defines attribute accessors on a given target class using Cattri's Context.
|
5
|
+
#
|
6
|
+
# This class provides a set of utility methods to generate reader and writer
|
7
|
+
# methods dynamically, with support for default values, coercion, and memoization.
|
8
|
+
#
|
9
|
+
# All accessors are defined through the Cattri::Context abstraction to ensure
|
10
|
+
# consistent scoping, visibility, and method tracking.
|
11
|
+
class AttributeDefiner
|
12
|
+
class << self
|
13
|
+
# Defines a callable accessor for class-level attributes.
|
14
|
+
#
|
15
|
+
# The generated method:
|
16
|
+
# - Returns the memoized default value when called with no args or if readonly
|
17
|
+
# - Otherwise, calls the attribute’s setter and memoizes the result
|
18
|
+
#
|
19
|
+
# If the attribute is not readonly, a writer (`foo=`) is also defined.
|
20
|
+
#
|
21
|
+
# @param attribute [Cattri::Attribute]
|
22
|
+
# @param context [Cattri::Context]
|
23
|
+
# @return [void]
|
24
|
+
# @raise [Cattri::AttributeError] if the setter raises an error
|
25
|
+
def define_callable_accessor(attribute, context)
|
26
|
+
return unless attribute.class_level?
|
27
|
+
|
28
|
+
context.define_method(attribute) do |*args, **kwargs|
|
29
|
+
readonly = (args.empty? && kwargs.empty?) || attribute[:readonly]
|
30
|
+
return AttributeDefiner.send(:memoize_default_value, self, attribute) if readonly
|
31
|
+
|
32
|
+
value = attribute.invoke_setter(*args, **kwargs)
|
33
|
+
instance_variable_set(attribute.ivar, value)
|
34
|
+
end
|
35
|
+
|
36
|
+
define_writer(attribute, context) unless attribute[:readonly]
|
37
|
+
end
|
38
|
+
|
39
|
+
# Defines an instance-level reader for class-level attributes.
|
40
|
+
#
|
41
|
+
# This method delegates the instance-level call to the class method.
|
42
|
+
# It is used when `instance_reader: true` is specified.
|
43
|
+
#
|
44
|
+
# @param attribute [Cattri::Attribute]
|
45
|
+
# @param context [Cattri::Context]
|
46
|
+
# @return [void]
|
47
|
+
def define_instance_level_reader(attribute, context)
|
48
|
+
return unless attribute.class_level?
|
49
|
+
|
50
|
+
context.target.define_method(attribute.name) do
|
51
|
+
self.class.__send__(attribute.name)
|
52
|
+
end
|
53
|
+
|
54
|
+
context.send(:apply_access, attribute.name, attribute)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Defines standard reader and writer methods for instance-level attributes.
|
58
|
+
#
|
59
|
+
# Skips definition if `reader: false` or `writer: false` is specified.
|
60
|
+
#
|
61
|
+
# @param attribute [Cattri::Attribute]
|
62
|
+
# @param context [Cattri::Context]
|
63
|
+
# @return [void]
|
64
|
+
def define_accessor(attribute, context)
|
65
|
+
define_reader(attribute, context) if attribute[:reader]
|
66
|
+
define_writer(attribute, context) if attribute[:writer]
|
67
|
+
end
|
68
|
+
|
69
|
+
# Defines a memoizing reader for the given attribute.
|
70
|
+
#
|
71
|
+
# This is used for both class and instance attributes, and ensures that
|
72
|
+
# the default value is computed only once and stored in the ivar.
|
73
|
+
#
|
74
|
+
# @param attribute [Cattri::Attribute]
|
75
|
+
# @param context [Cattri::Context]
|
76
|
+
# @return [void]
|
77
|
+
def define_reader(attribute, context)
|
78
|
+
context.define_method(attribute) do
|
79
|
+
AttributeDefiner.send(:memoize_default_value, self, attribute)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Defines a writer method (`foo=`) that sets and coerces a value via the attribute setter.
|
84
|
+
#
|
85
|
+
# @param attribute [Cattri::Attribute]
|
86
|
+
# @param context [Cattri::Context]
|
87
|
+
# @return [void]
|
88
|
+
def define_writer(attribute, context)
|
89
|
+
context.define_method(attribute, name: :"#{attribute.name}=") do |value|
|
90
|
+
coerced_value = attribute.setter.call(value)
|
91
|
+
instance_variable_set(attribute.ivar, coerced_value)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
# Returns the memoized value for an attribute or computes it from the default.
|
98
|
+
#
|
99
|
+
# This helper ensures lazy initialization while guarding against errors in the default proc.
|
100
|
+
#
|
101
|
+
# @param receiver [Object]
|
102
|
+
# @param attribute [Cattri::Attribute]
|
103
|
+
# @return [Object]
|
104
|
+
# @raise [Cattri::AttributeError] if the default block raises an error
|
105
|
+
def memoize_default_value(receiver, attribute)
|
106
|
+
return receiver.instance_variable_get(attribute.ivar) if receiver.instance_variable_defined?(attribute.ivar)
|
107
|
+
|
108
|
+
receiver.instance_variable_set(attribute.ivar, attribute.invoke_default)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|