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 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
+ ![Minitwin Logo](logo.png){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