minitwin 1.0.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 +7 -0
- data/LICENSE +7 -0
- data/README.md +99 -0
- data/USAGE.md +390 -0
- data/lib/minitwin/assignment.rb +95 -0
- data/lib/minitwin/class_methods/caches.rb +94 -0
- data/lib/minitwin/class_methods/coercion.rb +114 -0
- data/lib/minitwin/class_methods/constructors.rb +120 -0
- data/lib/minitwin/class_methods/dsl.rb +404 -0
- data/lib/minitwin/class_methods/rbs.rb +129 -0
- data/lib/minitwin/class_methods/types_helper.rb +70 -0
- data/lib/minitwin/class_methods.rb +26 -0
- data/lib/minitwin/initialization.rb +188 -0
- data/lib/minitwin/railtie.rb +9 -0
- data/lib/minitwin/serialization.rb +172 -0
- data/lib/minitwin/sync.rb +137 -0
- data/lib/minitwin/version.rb +5 -0
- data/lib/minitwin.rb +129 -0
- data/lib/tasks/minitwin.rake +70 -0
- data/sig/generated/minitwin/assignment.rbs +29 -0
- data/sig/generated/minitwin/class_methods/caches.rbs +40 -0
- data/sig/generated/minitwin/class_methods/constructors.rbs +41 -0
- data/sig/generated/minitwin/class_methods/dsl.rbs +79 -0
- data/sig/generated/minitwin/class_methods/rbs.rbs +20 -0
- data/sig/generated/minitwin/initialization.rbs +45 -0
- data/sig/generated/minitwin/serialization.rbs +47 -0
- data/sig/generated/minitwin/sync.rbs +19 -0
- data/sig/module.rbs +19 -0
- metadata +89 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f0d2a4f4cc32ed09cb11728a1f8b81edf576da9cfe341a3cf287a42d37d1adf5
|
|
4
|
+
data.tar.gz: 23ea54c1c77af14a3782a70e617e4bc4fbbf206cc5f72df45de5ca67c7e0939c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a6f55c0a7e9b703b6451174d71f9835d3436728cad385a592133f385699a57fb26389255c073a5d4d043e970439d98f69126cc87b989a392a9586eb8689efcf6
|
|
7
|
+
data.tar.gz: 72b755ef287bccb6eff9e7f3d9d8e3b32457cedf24f02bd1f93bab5679a7f1358d7dbdf1577011c41dd567bb820f7e67fba78e7b4f0d3ff460e8481ce8120bec
|
data/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 webit!
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Minitwin
|
|
2
|
+
|
|
3
|
+
{width=20%}
|
|
4
|
+
|
|
5
|
+
## What is Minitwin?
|
|
6
|
+
|
|
7
|
+
It is a tiny presentation layer with a small DSL to define properties, collections, light type coercion via dry-types, and optional ActiveModel validations. It's designed to be framework-friendly but not framework-bound.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## Dependencies
|
|
11
|
+
|
|
12
|
+
- **Ruby** `>= 3.3`
|
|
13
|
+
- **[dry-types](https://dry-rb.org/gems/dry-types)** _(optional)_ — enables the `type:` coercion option on properties
|
|
14
|
+
- **[activesupport](https://github.com/rails/rails/tree/main/activesupport)** _(optional)_ — `to_hash` returns `HashWithIndifferentAccess` when available, otherwise a plain Hash
|
|
15
|
+
- **[activemodel](https://github.com/rails/rails/tree/main/activemodel)** _(optional)_ — enables the `validates:` DSL and `valid?`; without it, twins are always considered valid
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## Getting Started
|
|
19
|
+
|
|
20
|
+
Add to your Gemfile:
|
|
21
|
+
|
|
22
|
+
`gem "minitwin"`
|
|
23
|
+
|
|
24
|
+
Or build and install locally:
|
|
25
|
+
|
|
26
|
+
`gem build minitwin.gemspec && gem install minitwin-*.gem`
|
|
27
|
+
|
|
28
|
+
Then define your first Twin:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
class UserTwin < Minitwin
|
|
32
|
+
property :id, type: Types::Params::Integer.lax
|
|
33
|
+
property :name, validates: { presence: true }
|
|
34
|
+
property :active, type: Types::Params::Bool.lax
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
user = UserTwin.from_hash(
|
|
38
|
+
id: "42",
|
|
39
|
+
name: "Alex",
|
|
40
|
+
active: "1"
|
|
41
|
+
)
|
|
42
|
+
user.id #=> 42 (coerced)
|
|
43
|
+
user.name #=> "Alex"
|
|
44
|
+
user.active #=> true (coerced)
|
|
45
|
+
|
|
46
|
+
user.to_json
|
|
47
|
+
#=> '{"id":42,"name":"Alex","active":true}'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
See [USAGE](./USAGE.md) for further examples.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
## RBS
|
|
54
|
+
|
|
55
|
+
Minitwin comes with basic RBS signature files for its public interface. If you want to use them in
|
|
56
|
+
your project, you have to declare the dependency explicitly in your `rbs_collection.yaml` like so:
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
gems:
|
|
60
|
+
- name: minitwin
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Furthermore, Minitwin ships with a rake task, which generates the RBS signature files for all your
|
|
64
|
+
classes inheriting from `Minitwin`.
|
|
65
|
+
|
|
66
|
+
If you use Rails, the task is automatically loaded. Just run:
|
|
67
|
+
```bash
|
|
68
|
+
rails minitwin:generate_rbs
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If you work with plain Ruby, you have to load the task in your `Rakefile`:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
load Gem.find_files("tasks/minitwin.rake").first
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then run the task:
|
|
78
|
+
```bash
|
|
79
|
+
rake minitwin:generate_rbs
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
By default, the task will output the rbs files in `sig/generated/`. You can adjust this by setting
|
|
83
|
+
a task argument or an ENV var `MINITWIN_RBS_DIR`. If both is set, the argument will be used.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
rake minitwin:generate_rbs[sig/custom_path]
|
|
87
|
+
|
|
88
|
+
MINITWIN_RBS_DIR=sig/custom_path rake minitwin:generate_rbs
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
## Inspiration
|
|
93
|
+
|
|
94
|
+
Minitwin was inspired by [Disposable](https://github.com/apotonick/disposable), a gem for building twin objects as a decorator layer on top of your domain models.
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
Minitwin is licensed under the MIT License. See [LICENSE](./LICENSE) for more details.
|
data/USAGE.md
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# Usage
|
|
2
|
+
|
|
3
|
+
- [Basic](#basic)
|
|
4
|
+
- [Block properties](#block-properties)
|
|
5
|
+
- [Collection properties](#collection-properties)
|
|
6
|
+
- [Nested properties](#nested-properties)
|
|
7
|
+
- [Aliases](#aliases)
|
|
8
|
+
- [Coercion](#coercion)
|
|
9
|
+
- [Validations](#validations)
|
|
10
|
+
- [Working with objects](#working-with-objects)
|
|
11
|
+
- [Composition](#composition)
|
|
12
|
+
- [DSL Reference](#dsl-reference)
|
|
13
|
+
- [Public Interface](#public-interface)
|
|
14
|
+
|
|
15
|
+
## Basic
|
|
16
|
+
|
|
17
|
+
Define properties and instantiate a twin from a hash:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
class ArticleTwin < Minitwin
|
|
21
|
+
property :title
|
|
22
|
+
property :published
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
article = ArticleTwin.from_hash(title: "Hello", published: true)
|
|
26
|
+
article.title #=> "Hello"
|
|
27
|
+
article.published #=> true
|
|
28
|
+
|
|
29
|
+
article.to_hash #=> { title: "Hello", published: true }
|
|
30
|
+
article.to_json #=> '{"title":"Hello","published":true}'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Block properties
|
|
34
|
+
|
|
35
|
+
A block creates an anonymous nested twin class for the property:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class PostTwin < Minitwin
|
|
39
|
+
property :title
|
|
40
|
+
property :author do
|
|
41
|
+
property :name
|
|
42
|
+
property :email
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
post = PostTwin.from_hash(title: "Hi", author: { name: "Ana", email: "ana@example.com" })
|
|
47
|
+
post.author.name #=> "Ana"
|
|
48
|
+
post.to_hash #=> { title: "Hi", author: { name: "Ana", email: "ana@example.com" } }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Use `twin:` to reference an existing twin class instead of defining an inline block:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
class AddressTwin < Minitwin
|
|
55
|
+
property :city
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class UserTwin < Minitwin
|
|
59
|
+
property :name
|
|
60
|
+
property :address, twin: AddressTwin
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
user = UserTwin.from_hash(name: "Bob", address: { city: "Berlin" })
|
|
64
|
+
user.address.city #=> "Berlin"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Collection properties
|
|
68
|
+
|
|
69
|
+
`collection` defines an array property. Each element can be a plain value or a nested twin:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
class InvoiceTwin < Minitwin
|
|
73
|
+
collection :tags
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
inv = InvoiceTwin.from_hash(tags: %w[urgent vip])
|
|
77
|
+
inv.tags #=> ["urgent", "vip"]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
With a block, each element becomes a twin instance:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
class OrderTwin < Minitwin
|
|
84
|
+
collection :lines do
|
|
85
|
+
property :product
|
|
86
|
+
property :qty
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
order = OrderTwin.from_hash(lines: [
|
|
91
|
+
{ product: "Mug", qty: 2 },
|
|
92
|
+
{ product: "Shirt", qty: 1 }
|
|
93
|
+
])
|
|
94
|
+
order.lines.first.product #=> "Mug"
|
|
95
|
+
order.to_hash
|
|
96
|
+
#=> { lines: [{ product: "Mug", qty: 2 }, { product: "Shirt", qty: 1 }] }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Nested properties
|
|
100
|
+
|
|
101
|
+
`nested` groups properties under a container key in serialization while keeping a flat write API on the parent:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
class ProfileTwin < Minitwin
|
|
105
|
+
property :username
|
|
106
|
+
nested :settings do
|
|
107
|
+
property :theme
|
|
108
|
+
property :locale
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
t = ProfileTwin.from_hash(username: "dana", theme: "dark", locale: "en")
|
|
113
|
+
t.theme #=> "dark"
|
|
114
|
+
t.to_hash #=> { username: "dana", settings: { theme: "dark", locale: "en" } }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`nested` also accepts `as:` to rename the container key in serialization, the same
|
|
118
|
+
way `property` does. Because the block uses the plain `name` internally, the alias
|
|
119
|
+
may be any symbol — even one that is not a valid method or instance variable name:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
nested :settings, as: :"app:settings" do
|
|
123
|
+
property :theme
|
|
124
|
+
end
|
|
125
|
+
#=> { :"app:settings" => { theme: "dark" } }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Aliases
|
|
129
|
+
|
|
130
|
+
A static alias (symbol) renames the public getter and protects the original name. The serialized key follows the alias:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
class TokenTwin < Minitwin
|
|
134
|
+
property :internal_token, as: :token
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
t = TokenTwin.from_hash(internal_token: "abc")
|
|
138
|
+
t.token #=> "abc"
|
|
139
|
+
t.to_hash #=> { token: "abc" }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A dynamic alias (lambda) is evaluated per instance, so the public name can depend on other attributes:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
class FieldTwin < Minitwin
|
|
146
|
+
property :key
|
|
147
|
+
property :value, as: -> { key }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
t = FieldTwin.from_hash(key: "score", value: 42)
|
|
151
|
+
t.score #=> 42
|
|
152
|
+
t.to_hash #=> { key: "score", score: 42 }
|
|
153
|
+
|
|
154
|
+
t.key = "total"
|
|
155
|
+
t.value = 99
|
|
156
|
+
t.total #=> 99
|
|
157
|
+
t.to_hash #=> { key: "total", total: 99 }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The lambda runs in instance context, so any reader on the twin is available. `as:` works the same way on `collection`.
|
|
161
|
+
|
|
162
|
+
## Coercion
|
|
163
|
+
|
|
164
|
+
Use `type:` with dry-types to coerce values on assignment. Coercion errors fall back to the raw value instead of raising:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
class EventTwin < Minitwin
|
|
168
|
+
property :visitor_count, type: Types::Params::Integer.lax
|
|
169
|
+
property :active, type: Types::Params::Bool.lax
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
ev = EventTwin.from_hash(visitor_count: "42", active: "1")
|
|
173
|
+
ev.visitor_count #=> 42
|
|
174
|
+
ev.active #=> true
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Validations
|
|
178
|
+
|
|
179
|
+
When ActiveModel is available, pass `validates:` to apply validations. Errors from nested twins and collections are aggregated on the parent:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
class ContactTwin < Minitwin
|
|
183
|
+
property :email, validates: { presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } }
|
|
184
|
+
property :name, validates: { presence: true }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
c = ContactTwin.from_hash(email: "", name: "")
|
|
188
|
+
c.valid? #=> false
|
|
189
|
+
c.errors.full_messages
|
|
190
|
+
#=> ["Email can't be blank", "Email is invalid", "Name can't be blank"]
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Validations propagate from nested twins:
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
class RegistrationTwin < Minitwin
|
|
197
|
+
property :contact do
|
|
198
|
+
property :email, validates: { presence: true }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
r = RegistrationTwin.from_hash(contact: { email: "" })
|
|
203
|
+
r.valid? #=> false
|
|
204
|
+
r.errors.full_messages #=> ["contact.email can't be blank"]
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Working with objects
|
|
208
|
+
|
|
209
|
+
`from_object` reads attributes from any object with `attributes`, `to_h`, or readers:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
class UserTwin < Minitwin
|
|
213
|
+
property :name
|
|
214
|
+
property :email
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
User = Data.define(:name, :email)
|
|
218
|
+
user = UserTwin.from_object(User.new(name: "Alex", email: "alex@example.com"))
|
|
219
|
+
user.name #=> "Alex"
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`assign_hash` updates an existing twin in place (only known attributes):
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
twin = UserTwin.from_hash(name: "Alex", email: "old@example.com")
|
|
226
|
+
twin.assign_hash(email: "new@example.com")
|
|
227
|
+
twin.email #=> "new@example.com"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`sync` writes values back to the original model. It uses the stored reference from `from_object`, so no argument is needed:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
class ItemTwin < Minitwin
|
|
234
|
+
property :name
|
|
235
|
+
property :price
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
class Item
|
|
239
|
+
attr_accessor :name, :price
|
|
240
|
+
|
|
241
|
+
def initialize(name:, price:)
|
|
242
|
+
@name = name
|
|
243
|
+
@price = price
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
item = Item.new(name: "Book", price: 10)
|
|
248
|
+
twin = ItemTwin.from_object(item)
|
|
249
|
+
|
|
250
|
+
twin.price = 20
|
|
251
|
+
twin.sync #=> true
|
|
252
|
+
|
|
253
|
+
item.price #=> 20
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Composition
|
|
257
|
+
|
|
258
|
+
Use `on:` to read a property from a specific source object instead of storing the value on the twin itself:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
class SummaryTwin < Minitwin
|
|
262
|
+
property :id, on: :order
|
|
263
|
+
property :total, on: :order
|
|
264
|
+
property :name, on: :customer
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
Order = Data.define(:id, :total)
|
|
268
|
+
Customer = Data.define(:name)
|
|
269
|
+
|
|
270
|
+
summary = SummaryTwin.from_objects(
|
|
271
|
+
order: Order.new(id: 7, total: 99.0),
|
|
272
|
+
customer: Customer.new(name: "Dana")
|
|
273
|
+
)
|
|
274
|
+
summary.id #=> 7
|
|
275
|
+
summary.name #=> "Dana"
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## DSL Reference
|
|
281
|
+
|
|
282
|
+
### `property`
|
|
283
|
+
|
|
284
|
+
| Option | Description |
|
|
285
|
+
|---|---|
|
|
286
|
+
| `as:` | Public getter name. Protects the original name. Accepts a symbol or a lambda `-> { ... }` for dynamic aliases computed per instance. |
|
|
287
|
+
| `default:` | Default value when the property is `nil`. Accepts a callable (`-> { ... }`) for computed defaults. |
|
|
288
|
+
| `type:` | A dry-types type for coercion on assignment (e.g. `Types::Params::Integer.lax`). Errors fall back to the raw value. |
|
|
289
|
+
| `twin:` | Wraps the value in another twin class. Accepts a hash, a twin instance, or an object with `to_h`/`attributes`. |
|
|
290
|
+
| `expose:` | `false` omits the property from `to_hash`/`to_json`. |
|
|
291
|
+
| `readonly:` | `true` prevents assignment via `assign_hash` and `assign_params`. |
|
|
292
|
+
| `getter:` | A lambda `-> { ... }` or a symbol `:method_name` that fully replaces the generated getter. The lambda runs in instance context; the symbol calls the named method on the instance. If the lambda or method accepts a parameter, the current raw property value is passed as the argument. |
|
|
293
|
+
| `setter:` | A lambda `->(value) { ... }` that fully replaces the generated setter. Not allowed together with a block. |
|
|
294
|
+
| `on:` | Reads the value from a named composition source (see `from_objects`). |
|
|
295
|
+
| `validates:` | ActiveModel validation options, e.g. `{ presence: true }`. Ignored when ActiveModel is not available. |
|
|
296
|
+
| block | Defines an inline nested twin class. Mutually exclusive with `setter:`. |
|
|
297
|
+
|
|
298
|
+
### `collection`
|
|
299
|
+
|
|
300
|
+
| Option | Description |
|
|
301
|
+
|---|---|
|
|
302
|
+
| `as:` | Public getter name. Accepts a symbol or a lambda `-> { ... }` for dynamic aliases. |
|
|
303
|
+
| `default:` | Default value. Defaults to `[]`. |
|
|
304
|
+
| `twin:` | Wraps each element in the given twin class. |
|
|
305
|
+
| `getter:` | A lambda `-> { ... }` or a symbol `:method_name` that fully replaces the generated getter. The lambda runs in instance context; the symbol calls the named method on the instance. If the lambda or method accepts a parameter, the current raw property value is passed as the argument. |
|
|
306
|
+
| `on:` | Reads the collection from a named composition source. Each element is wrapped in the element twin when one is configured. |
|
|
307
|
+
| `validates:` | ActiveModel validation options applied to the collection property itself. |
|
|
308
|
+
| block | Defines an inline nested twin class used for each element. |
|
|
309
|
+
|
|
310
|
+
### `nested`
|
|
311
|
+
|
|
312
|
+
Groups properties under a container key in serialization while keeping a flat read/write API on the parent twin. Requires a block.
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
nested :address do
|
|
316
|
+
property :city
|
|
317
|
+
property :zip
|
|
318
|
+
end
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
The leaf properties (`city`, `zip`) are accessible directly on the parent instance. `to_hash` places them under the `address` key.
|
|
322
|
+
|
|
323
|
+
| Option | Description |
|
|
324
|
+
|---|---|
|
|
325
|
+
| block | Required. Defines the nested twin class. |
|
|
326
|
+
| `as:` | Renames the container key in serialization. Accepts any symbol, including one that is not a valid identifier. |
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## Public Interface
|
|
331
|
+
|
|
332
|
+
### Class methods
|
|
333
|
+
|
|
334
|
+
**Constructors**
|
|
335
|
+
|
|
336
|
+
| Method | Description |
|
|
337
|
+
|---|---|
|
|
338
|
+
| `from_hash(hash)` | Instantiates a twin from a plain Ruby Hash. |
|
|
339
|
+
| `from_json(string)` | Parses a JSON string and delegates to `from_hash`. |
|
|
340
|
+
| `from_params(params)` | Accepts `ActionController::Parameters` or a plain Hash. Unwraps `to_unsafe_h` automatically. |
|
|
341
|
+
| `from_object(model)` | Reads attributes from a single object via `attributes`, `to_h`, or readers. Stores the object for later `sync`. |
|
|
342
|
+
| `from_objects(**models)` | Merges attributes from multiple named objects. Last value wins on key conflicts. Stored objects are available as composition sources via `on:`. |
|
|
343
|
+
| `from_collection(array)` | Applies `from_objects` semantics to each element and returns an array of twins. |
|
|
344
|
+
|
|
345
|
+
**Other**
|
|
346
|
+
|
|
347
|
+
| Method | Description |
|
|
348
|
+
|---|---|
|
|
349
|
+
| `to_rbs` | Returns an RBS signature string for the twin class. |
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
### Instance methods
|
|
354
|
+
|
|
355
|
+
**Serialization**
|
|
356
|
+
|
|
357
|
+
| Method | Description |
|
|
358
|
+
|---|---|
|
|
359
|
+
| `to_hash(render_nil: false)` | Returns the twin as a Hash (or `HashWithIndifferentAccess` when ActiveSupport is available). Nested twins are serialized recursively. Unexposed properties are omitted. |
|
|
360
|
+
| `to_h` | Alias for `to_hash`. |
|
|
361
|
+
| `to_json` | Delegates to `to_hash.to_json`. |
|
|
362
|
+
| `attributes` | Returns a Hash keyed by the original setter names (before any `as:` aliasing). Includes protected readers. |
|
|
363
|
+
| `pretty_print(q)` | Integrates with Ruby's `pp` library. Outputs the twin with class name, properties in definition order, and nested twins recursively formatted with their own class names. |
|
|
364
|
+
|
|
365
|
+
**Validation**
|
|
366
|
+
|
|
367
|
+
| Method | Description |
|
|
368
|
+
|---|---|
|
|
369
|
+
| `valid?` | Returns `true` when all validations pass. Without ActiveModel, always returns `true`. Errors from nested twins and collections are aggregated with dot/bracket paths (e.g. `contact.email`, `lines[0].qty`). |
|
|
370
|
+
|
|
371
|
+
**Assignment**
|
|
372
|
+
|
|
373
|
+
| Method | Description |
|
|
374
|
+
|---|---|
|
|
375
|
+
| `assign_hash(hash)` | Updates known attributes in place from a Hash. Recurses into nested twins and collection elements. |
|
|
376
|
+
| `assign_params(params)` | Like `assign_hash`, but also accepts `ActionController::Parameters`. |
|
|
377
|
+
| `assign_object(model)` | Copies matching attributes from an object via its readers and stores the object for later `sync`. |
|
|
378
|
+
| `to_object(model)` | Mirrors the twin's values into an existing model via its writers. Does not store the model. |
|
|
379
|
+
|
|
380
|
+
**Sync**
|
|
381
|
+
|
|
382
|
+
| Method | Description |
|
|
383
|
+
|---|---|
|
|
384
|
+
| `sync(model = nil, validate: true)` | Writes the twin's values back to the model. When `model` is omitted, uses the object stored by `from_object`/`assign_object`. Returns `false` when validation fails or no model is available. Recurses into nested twins and collection elements. |
|
|
385
|
+
|
|
386
|
+
**Introspection**
|
|
387
|
+
|
|
388
|
+
| Method | Description |
|
|
389
|
+
|---|---|
|
|
390
|
+
| `dynamic_aliases` | Returns a Hash of `alias_name => target_method` for all dynamic aliases active on the current instance. |
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class Minitwin
|
|
5
|
+
# Assign/update helpers to merge incoming data into an existing twin.
|
|
6
|
+
# - assign_object: copy readable attributes from an object and remember it
|
|
7
|
+
# - assign_hash / assign_params: update only known attributes, recursing into
|
|
8
|
+
# nested twins and collection items when possible
|
|
9
|
+
module Assignment
|
|
10
|
+
|
|
11
|
+
# Mirror values from a model's getters into this twin's setters
|
|
12
|
+
#: (untyped) -> instance
|
|
13
|
+
def to_object(model)
|
|
14
|
+
assignable_attribute_methods.each do |method|
|
|
15
|
+
next unless model.respond_to?(method)
|
|
16
|
+
|
|
17
|
+
value = model.public_send(method)
|
|
18
|
+
|
|
19
|
+
ivar_name = Minitwin::Utils.ivar_name(method)
|
|
20
|
+
current_value = instance_variable_get(ivar_name) if instance_variable_defined?(ivar_name)
|
|
21
|
+
|
|
22
|
+
if current_value.is_a?(Minitwin) && !value.nil? && !value.is_a?(Hash)
|
|
23
|
+
current_value.to_object(value)
|
|
24
|
+
elsif respond_to?("#{method}=", true)
|
|
25
|
+
send("#{method}=", value)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
#: (untyped) -> instance
|
|
32
|
+
def assign_object(model)
|
|
33
|
+
to_object(model)
|
|
34
|
+
instance_variable_set(self.class.internal_model_name("model"), model)
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#: (Hash[ String | Symbol, untyped ] hash) -> instance
|
|
39
|
+
def assign_hash(hash = {})
|
|
40
|
+
hash = hash.to_h.transform_keys(&:to_sym)
|
|
41
|
+
allowed = assignable_attribute_methods
|
|
42
|
+
|
|
43
|
+
was_skipping = @__skip_alias_recompute__
|
|
44
|
+
@__skip_alias_recompute__ = true
|
|
45
|
+
begin
|
|
46
|
+
hash.each do |method, value|
|
|
47
|
+
next unless allowed.include?(method)
|
|
48
|
+
|
|
49
|
+
ivar_name = Minitwin::Utils.ivar_name(method)
|
|
50
|
+
current_value = instance_variable_get(ivar_name) if instance_variable_defined?(ivar_name)
|
|
51
|
+
|
|
52
|
+
if current_value.respond_to?(:assign_hash) && value.is_a?(Hash)
|
|
53
|
+
current_value.assign_hash(value)
|
|
54
|
+
elsif value.is_a?(Array) && current_value.is_a?(Array)
|
|
55
|
+
value.each_with_index do |item, idx|
|
|
56
|
+
if item.is_a?(Hash) && current_value.size > idx && current_value[idx].respond_to?(:assign_hash)
|
|
57
|
+
current_value[idx].assign_hash(item)
|
|
58
|
+
else
|
|
59
|
+
current_value[idx] = item
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
elsif respond_to?("#{method}=", true)
|
|
63
|
+
send("#{method}=", value)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
ensure
|
|
67
|
+
@__skip_alias_recompute__ = was_skipping
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if !@__skip_alias_recompute__ && self.class.dynamic_aliases?
|
|
71
|
+
__recompute_dynamic_aliases__
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Actually, this is expected to be an `ActionController::Parameters`
|
|
78
|
+
# object. The type will be unknown when used without rails. So for RBS
|
|
79
|
+
# the argument is typed `untyped`.
|
|
80
|
+
#: (untyped params) -> instance
|
|
81
|
+
def assign_params(params = {})
|
|
82
|
+
params = params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
|
|
83
|
+
assign_hash(params)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Gets the non-readonly methods, which can be assigned with new values.
|
|
87
|
+
#: () -> Array[Symbol]
|
|
88
|
+
def assignable_attribute_methods
|
|
89
|
+
attribute_methods.reject do |method|
|
|
90
|
+
prop_meta = self.class.properties[method]
|
|
91
|
+
prop_meta && prop_meta[:readonly]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
class Minitwin
|
|
5
|
+
module ClassMethods
|
|
6
|
+
module Caches
|
|
7
|
+
|
|
8
|
+
#: () -> bool
|
|
9
|
+
def dynamic_aliases?
|
|
10
|
+
return @has_dynamic_aliases_cache unless @has_dynamic_aliases_cache.nil?
|
|
11
|
+
|
|
12
|
+
@has_dynamic_aliases_cache = begin
|
|
13
|
+
procs = properties.any? { |_, m| m[:as].is_a?(Proc) } ||
|
|
14
|
+
collections.any? { |_, m| m[:as].is_a?(Proc) }
|
|
15
|
+
nested = respond_to?(:dynamic_nested_aliases) && dynamic_nested_aliases.any?
|
|
16
|
+
procs || nested
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def invalidate_caches
|
|
23
|
+
@serializable_getters = nil
|
|
24
|
+
@allowed_attribute_keys = nil
|
|
25
|
+
@allowed_attribute_keys_array = nil
|
|
26
|
+
@setter_methods = nil
|
|
27
|
+
@has_dynamic_aliases_cache = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def serializable_getters
|
|
31
|
+
@serializable_getters ||= begin
|
|
32
|
+
unexposed = unexposed_properties.to_set(&:to_sym)
|
|
33
|
+
prot = (protected_instance_methods - Minitwin.protected_instance_methods).to_set
|
|
34
|
+
declared = declared_property_keys
|
|
35
|
+
own_and_inherited = serializable_method_candidates
|
|
36
|
+
own_and_inherited.reject do |m|
|
|
37
|
+
s = m.to_s
|
|
38
|
+
s.end_with?("=", "?", "_attributes") || unexposed.include?(m) || prot.include?(m) ||
|
|
39
|
+
!declared.include?(instance_method(m).original_name)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Methods defined directly on the twin class hierarchy: this class and any
|
|
45
|
+
# intermediate Minitwin subclasses, but not Minitwin itself. Methods mixed
|
|
46
|
+
# in via modules (e.g. ActionView helpers, which include arg-taking methods
|
|
47
|
+
# like #link_to) are excluded because instance_methods(false) reports only
|
|
48
|
+
# methods owned by the class, not by included modules.
|
|
49
|
+
def serializable_method_candidates
|
|
50
|
+
twin_class_hierarchy.flat_map { |klass| klass.instance_methods(false) }.uniq
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Base names of every declared property and collection across the twin
|
|
54
|
+
# class hierarchy. Only methods whose declaration name is one of these
|
|
55
|
+
# serialize, so plain getters hand-defined on a twin are excluded. `as:`
|
|
56
|
+
# aliases pass because #original_name maps them back to the original
|
|
57
|
+
# property (the aliased reader is made protected and rejected separately).
|
|
58
|
+
def declared_property_keys
|
|
59
|
+
twin_class_hierarchy.each_with_object(Set.new) do |klass, keys|
|
|
60
|
+
keys.merge(klass.properties.keys)
|
|
61
|
+
keys.merge(klass.collections.keys)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# This class and any intermediate Minitwin subclasses, but not Minitwin
|
|
66
|
+
# itself or mixed-in modules.
|
|
67
|
+
def twin_class_hierarchy
|
|
68
|
+
ancestors.take_while { |a| a != Minitwin }.select { |a| a.instance_of?(Class) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def allowed_attribute_keys
|
|
72
|
+
# Setters defined directly on the twin class hierarchy. Uses the same
|
|
73
|
+
# candidate set as serializable_getters so mixed-in module setters
|
|
74
|
+
# (e.g. ActionView's #output_buffer=) are not treated as assignable
|
|
75
|
+
# attributes.
|
|
76
|
+
@allowed_attribute_keys ||= serializable_method_candidates.
|
|
77
|
+
grep(/=\z/).to_set { |m| m.to_s.delete_suffix("=").to_sym }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def allowed_attribute_keys_array
|
|
81
|
+
@allowed_attribute_keys_array ||= begin
|
|
82
|
+
allowed = allowed_attribute_keys
|
|
83
|
+
ordered = property_order.select { |k| allowed.include?(k) }
|
|
84
|
+
remaining = allowed.to_a - ordered
|
|
85
|
+
(ordered + remaining).freeze
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def setter_methods
|
|
90
|
+
@setter_methods ||= public_instance_methods(false).select { |m| m.to_s.end_with?("=") }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|