active_record_compose 0.12.0 → 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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +123 -230
- data/lib/active_record_compose/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9cdfb23b1af43681e6c4af3e9fed615bc3dc64873461bdee6f49c92f706f33e9
|
4
|
+
data.tar.gz: 4530c0987f038605cb6dc3da229df21e8cf16af29d6ca8cf3078b1dc933cc838
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 10c37b41ff66acd993e2f5c412ed5f48213f29344a94e0ab27d40766e11f60197808d4d1b9e97118f729b4be1d272a4bdae100c8e06489c40008ac3c92f6fe61
|
7
|
+
data.tar.gz: 67c4086fce4661e89188161fd6d23610bca3dcb9ad8a6e781162909d07fbf750add3ad804d8e58bd92bf4fc1305bcebd2452a4a9d924af70e56e6a0217ecf70f
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# ActiveRecordCompose
|
2
2
|
|
3
|
-
|
3
|
+
ActiveRecordCompose lets you build form objects that combine multiple ActiveRecord models into a single, unified interface.
|
4
|
+
More than just a simple form object, it is designed as a **business-oriented composed model** that encapsulates complex operations-such as user registration spanning multiple tables-making them easier to write, validate, and maintain.
|
4
5
|
|
5
6
|
[](https://badge.fury.io/rb/active_record_compose)
|
6
7
|

|
@@ -10,16 +11,16 @@ activemodel (activerecord) form object pattern. it embraces multiple AR models a
|
|
10
11
|
|
11
12
|
- [Motivation](#motivation)
|
12
13
|
- [Installation](#installation)
|
13
|
-
- [
|
14
|
-
- [Basic
|
15
|
-
|
16
|
-
|
17
|
-
- [I18n](#i18n)
|
14
|
+
- [Quick Start](#quick-start)
|
15
|
+
- [Basic Example](#basic-example)
|
16
|
+
- [Attribute Delegation](#attribute-delegation)
|
17
|
+
- [Unified Error Handling](#unified-error-handling)
|
18
|
+
- [I18n Support](#i18n-support)
|
18
19
|
- [Advanced Usage](#advanced-usage)
|
19
|
-
- [
|
20
|
-
- [Callback ordering
|
21
|
-
- [
|
22
|
-
- [Sample
|
20
|
+
- [Destroy Option](#destroy-option)
|
21
|
+
- [Callback ordering with `#persisted?`](#callback-ordering-with-persisted)
|
22
|
+
- [Notes on adding models dynamically](#notes-on-adding-models-dynamically)
|
23
|
+
- [Sample Application](#sample-application)
|
23
24
|
- [Links](#links)
|
24
25
|
- [Development](#development)
|
25
26
|
- [Contributing](#contributing)
|
@@ -28,11 +29,20 @@ activemodel (activerecord) form object pattern. it embraces multiple AR models a
|
|
28
29
|
|
29
30
|
## Motivation
|
30
31
|
|
31
|
-
`ActiveRecord::Base` is responsible for persisting data to the database
|
32
|
+
In Rails, `ActiveRecord::Base` is responsible for persisting data to the database.
|
33
|
+
By defining validations and callbacks, you can model use cases effectively.
|
32
34
|
|
33
|
-
|
35
|
+
However, when a single model must serve multiple different use cases, you often end up with conditional validations (`on: :context`) or workarounds like `save(validate: false)`.
|
36
|
+
This mixes unrelated concerns into one model, leading to unnecessary complexity.
|
34
37
|
|
35
|
-
|
38
|
+
`ActiveModel::Model` helps here — it provides the familiar API (`attribute`, `errors`, validations, callbacks) without persistence, so you can isolate logic per use case.
|
39
|
+
|
40
|
+
**ActiveRecordCompose** builds on `ActiveModel::Model` and is a powerful **business object** that acts as a first-class model within Rails.
|
41
|
+
- Transparently accesses attributes across multiple models
|
42
|
+
- Saves all associated models atomically in a transaction
|
43
|
+
- Collects and exposes error information consistently
|
44
|
+
|
45
|
+
This leads to cleaner domain models, better separation of concerns, and fewer surprises in validations and callbacks.
|
36
46
|
|
37
47
|
## Installation
|
38
48
|
|
@@ -48,15 +58,15 @@ Then bundle
|
|
48
58
|
$ bundle
|
49
59
|
```
|
50
60
|
|
51
|
-
##
|
61
|
+
## Quick Start
|
52
62
|
|
53
|
-
### Basic
|
63
|
+
### Basic Example
|
54
64
|
|
55
|
-
|
65
|
+
Suppose you have two models:
|
56
66
|
|
57
67
|
```ruby
|
58
68
|
class Account < ApplicationRecord
|
59
|
-
has_one :profile
|
69
|
+
has_one :profile
|
60
70
|
validates :name, :email, presence: true
|
61
71
|
end
|
62
72
|
|
@@ -66,40 +76,23 @@ class Profile < ApplicationRecord
|
|
66
76
|
end
|
67
77
|
```
|
68
78
|
|
69
|
-
|
79
|
+
You can compose them into one form object:
|
70
80
|
|
71
81
|
```ruby
|
72
82
|
class UserRegistration < ActiveRecordCompose::Model
|
73
|
-
def initialize
|
83
|
+
def initialize(attributes = {})
|
74
84
|
@account = Account.new
|
75
85
|
@profile = @account.build_profile
|
76
|
-
|
77
|
-
super() # Don't forget to call `super()`
|
78
|
-
# RuboCop's Lint/MissingSuper cop assists in addressing this.
|
79
|
-
|
86
|
+
super(attributes)
|
80
87
|
models << account << profile
|
81
|
-
# Alternatively, it can also be written as follows:
|
82
|
-
# models.push(account)
|
83
|
-
# models.push(profile)
|
84
88
|
end
|
85
89
|
|
86
|
-
# Attribute declarations using ActiveModel::Attributes are supported.
|
87
90
|
attribute :terms_of_service, :boolean
|
88
|
-
|
89
|
-
# You can provide validation definitions limited to UserRegistration.
|
90
|
-
# Instead of directly defining validations for Account or Profile, such
|
91
|
-
# as `on: :create` in the context, the model itself explains the context.
|
92
91
|
validates :terms_of_service, presence: true
|
93
92
|
validates :email, confirmation: true
|
94
93
|
|
95
|
-
# You can provide callback definitions limited to UserRegistration.
|
96
|
-
# For example, if this is written directly in the AR model, you need to consider
|
97
|
-
# callback control for data generation during tests and other situations.
|
98
94
|
after_commit :send_email_message
|
99
95
|
|
100
|
-
# UserRegistration behaves as if it has attributes like email, name, and age
|
101
|
-
# For example, `email` is delegated to `account.email`,
|
102
|
-
# and `email=` is delegated to `account.email=`.
|
103
96
|
delegate_attribute :name, :email, to: :account
|
104
97
|
delegate_attribute :firstname, :lastname, :age, to: :profile
|
105
98
|
|
@@ -113,12 +106,11 @@ class UserRegistration < ActiveRecordCompose::Model
|
|
113
106
|
end
|
114
107
|
```
|
115
108
|
|
116
|
-
|
109
|
+
Usage:
|
117
110
|
|
118
111
|
```ruby
|
112
|
+
# === Standalone script ===
|
119
113
|
registration = UserRegistration.new
|
120
|
-
|
121
|
-
# Atomically update Account and Profile.
|
122
114
|
registration.update!(
|
123
115
|
name: "foo",
|
124
116
|
email: "bar@example.com",
|
@@ -128,38 +120,41 @@ registration.update!(
|
|
128
120
|
email_confirmation: "bar@example.com",
|
129
121
|
terms_of_service: true,
|
130
122
|
)
|
131
|
-
```
|
132
123
|
|
133
|
-
|
124
|
+
# === Or, in a Rails controller with strong parameters ===
|
125
|
+
class UserRegistrationsController < ApplicationController
|
126
|
+
def create
|
127
|
+
@registration = UserRegistration.new(user_registration_params)
|
128
|
+
if @registration.save
|
129
|
+
redirect_to root_path, notice: "Registered!"
|
130
|
+
else
|
131
|
+
render :new
|
132
|
+
end
|
133
|
+
end
|
134
134
|
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
135
|
+
private
|
136
|
+
def user_registration_params
|
137
|
+
params.require(:user_registration).permit(
|
138
|
+
:name, :email, :firstname, :lastname, :age, :email_confirmation, :terms_of_service
|
139
|
+
)
|
140
|
+
end
|
141
|
+
end
|
140
142
|
```
|
141
143
|
|
142
|
-
|
144
|
+
Both `Account` and `Profile` will be updated **atomically in one transaction**.
|
145
|
+
|
146
|
+
### Attribute Delegation
|
143
147
|
|
144
|
-
|
148
|
+
`delegate_attribute` allows transparent access to attributes of inner models:
|
145
149
|
|
146
150
|
```ruby
|
147
|
-
|
148
|
-
|
149
|
-
# and `email=` is delegated to `account.email=`.
|
150
|
-
delegate_attribute :name, :email, to: :account
|
151
|
-
delegate_attribute :firstname, :lastname, :age, to: :profile
|
151
|
+
delegate_attribute :name, :email, to: :account
|
152
|
+
delegate_attribute :firstname, :lastname, :age, to: :profile
|
152
153
|
```
|
153
154
|
|
154
|
-
|
155
|
+
They are also included in `#attributes`:
|
155
156
|
|
156
157
|
```ruby
|
157
|
-
registration = UserRegistration.new
|
158
|
-
registration.name = "foo"
|
159
|
-
registration.terms_of_service = true
|
160
|
-
|
161
|
-
# Not only the email_confirmation defined with attribute,
|
162
|
-
# but also the attributes defined with delegate_attribute are included.
|
163
158
|
registration.attributes
|
164
159
|
# => {
|
165
160
|
# "terms_of_service" => true,
|
@@ -171,21 +166,21 @@ registration.attributes
|
|
171
166
|
# }
|
172
167
|
```
|
173
168
|
|
174
|
-
###
|
169
|
+
### Unified Error Handling
|
175
170
|
|
176
|
-
|
171
|
+
Validation errors from inner models are collected into the composed model:
|
177
172
|
|
178
173
|
```ruby
|
179
|
-
user_registration = UserRegistration.new
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
174
|
+
user_registration = UserRegistration.new(
|
175
|
+
email: "foo@example.com",
|
176
|
+
email_confirmation: "BAZ@example.com",
|
177
|
+
age: 18,
|
178
|
+
terms_of_service: true,
|
179
|
+
)
|
184
180
|
|
185
|
-
user_registration.save
|
186
|
-
#=> false
|
181
|
+
user_registration.save # => false
|
187
182
|
|
188
|
-
user_registration.errors.
|
183
|
+
user_registration.errors.full_messages
|
189
184
|
# => [
|
190
185
|
# "Name can't be blank",
|
191
186
|
# "Firstname can't be blank",
|
@@ -194,12 +189,10 @@ user_registration.errors.to_a
|
|
194
189
|
# ]
|
195
190
|
```
|
196
191
|
|
197
|
-
### I18n
|
192
|
+
### I18n Support
|
198
193
|
|
199
|
-
When
|
200
|
-
|
201
|
-
|
202
|
-
(Replace `en` as appropriate in the context.)
|
194
|
+
When `#save!` raises `ActiveRecord::RecordInvalid`,
|
195
|
+
make sure you have locale entries such as:
|
203
196
|
|
204
197
|
```yaml
|
205
198
|
en:
|
@@ -209,204 +202,104 @@ en:
|
|
209
202
|
record_invalid: 'Validation failed: %{errors}'
|
210
203
|
```
|
211
204
|
|
212
|
-
|
213
|
-
|
214
|
-
```yaml
|
215
|
-
en:
|
216
|
-
errors:
|
217
|
-
messages:
|
218
|
-
record_invalid: 'Validation failed: %{errors}'
|
219
|
-
```
|
205
|
+
For more complete usage patterns, see the [Sample Application](#sample-application) below.
|
220
206
|
|
221
207
|
## Advanced Usage
|
222
208
|
|
223
|
-
###
|
209
|
+
### Destroy Option
|
224
210
|
|
225
|
-
By adding to the models array while specifying destroy: true, you can perform a delete instead of a save on the model at #save time.
|
226
|
-
|
227
|
-
```ruby
|
228
|
-
class AccountResignation < ActiveRecordCompose::Model
|
229
|
-
def initialize(account)
|
230
|
-
@account = account
|
231
|
-
@profile = account.profile || account.build_profile
|
232
|
-
super()
|
233
|
-
models.push(account)
|
234
|
-
models.push(profile, destroy: true)
|
235
|
-
end
|
236
|
-
|
237
|
-
before_save :set_resigned_at
|
238
|
-
|
239
|
-
private
|
240
|
-
|
241
|
-
attr_reader :account, :profile
|
242
|
-
|
243
|
-
def set_resigned_at
|
244
|
-
account.resigned_at = Time.zone.now
|
245
|
-
end
|
246
|
-
end
|
247
|
-
```
|
248
211
|
```ruby
|
249
|
-
|
250
|
-
|
251
|
-
account.resigned_at.present? #=> nil
|
252
|
-
account.profile.blank? #=> false
|
253
|
-
|
254
|
-
account_resignation = AccountResignation.new(account)
|
255
|
-
account_resignation.save!
|
256
|
-
|
257
|
-
account.reload
|
258
|
-
account.resigned_at.present? #=> Tue, 02 Jan 2024 22:58:01.991008870 JST +09:00
|
259
|
-
account.profile.blank? #=> true
|
212
|
+
models.push(profile, destroy: true)
|
260
213
|
```
|
261
214
|
|
262
|
-
|
215
|
+
This deletes the model on `#save` instead of persisting it.
|
216
|
+
Conditional deletion is also supported:
|
263
217
|
|
264
218
|
```ruby
|
265
|
-
|
266
|
-
|
267
|
-
@account = account
|
268
|
-
@profile = account.profile || account.build_profile
|
269
|
-
super()
|
270
|
-
models.push(account)
|
271
|
-
|
272
|
-
# destroy if all blank, otherwise save.
|
273
|
-
models.push(profile, destroy: :profile_field_is_blank?)
|
274
|
-
# Alternatively, it can also be written as follows:
|
275
|
-
# models.push(profile, destroy: -> { profile_field_is_blank? })
|
276
|
-
end
|
219
|
+
models.push(profile, destroy: -> { profile_field_is_blank? })
|
220
|
+
```
|
277
221
|
|
278
|
-
|
279
|
-
delegate_attribute :name, :age, to: :profile
|
222
|
+
### Callback ordering with `#persisted?`
|
280
223
|
|
281
|
-
|
224
|
+
The result of `#persisted?` determines **which callbacks are fired**:
|
282
225
|
|
283
|
-
|
226
|
+
- `persisted? == false` -> create callbacks (`before_create`, `after_create`, ...)
|
227
|
+
- `persisted? == true` -> update callbacks (`before_update`, `after_update`, ...)
|
284
228
|
|
285
|
-
|
286
|
-
firstname.blank? && lastname.blank? && age.blank?
|
287
|
-
end
|
288
|
-
end
|
289
|
-
```
|
290
|
-
|
291
|
-
### Callback ordering by `#persisted?`
|
292
|
-
|
293
|
-
The behavior of `(before|after|around)_create` and `(before|after|around)_update` hooks depending on the evaluation result of `#persisted?`,
|
294
|
-
either the create-related callbacks or the update-related callbacks will be triggered.
|
229
|
+
This matches the behavior of normal ActiveRecord models.
|
295
230
|
|
296
231
|
```ruby
|
297
232
|
class ComposedModel < ActiveRecordCompose::Model
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
after_save
|
304
|
-
after_create { puts 'after_create called!' }
|
305
|
-
after_update { puts 'after_update called!' }
|
233
|
+
before_save { puts "before_save" }
|
234
|
+
before_create { puts "before_create" }
|
235
|
+
before_update { puts "before_update" }
|
236
|
+
after_create { puts "after_create" }
|
237
|
+
after_update { puts "after_update" }
|
238
|
+
after_save { puts "after_save" }
|
306
239
|
|
307
240
|
def persisted?
|
308
|
-
|
309
|
-
# For example, it could be transferred to the primary model to be manipulated.
|
310
|
-
#
|
311
|
-
# # ex.)
|
312
|
-
# def persisted? = the_model.persisted?
|
313
|
-
#
|
314
|
-
true
|
241
|
+
account.persisted?
|
315
242
|
end
|
316
243
|
end
|
317
244
|
```
|
318
245
|
|
319
|
-
|
320
|
-
# when `model.persisted?` returns `true`
|
246
|
+
Example:
|
321
247
|
|
248
|
+
```ruby
|
249
|
+
# When persisted? == false
|
322
250
|
model = ComposedModel.new
|
323
251
|
|
324
|
-
model.save
|
325
|
-
|
326
|
-
#
|
327
|
-
#
|
328
|
-
#
|
329
|
-
# after_save called!
|
330
|
-
```
|
331
|
-
|
332
|
-
```ruby
|
333
|
-
# when `model.persisted?` returns `false`
|
252
|
+
model.save
|
253
|
+
# => before_save
|
254
|
+
# => before_create
|
255
|
+
# => after_create
|
256
|
+
# => after_save
|
334
257
|
|
258
|
+
# When persisted? == true
|
335
259
|
model = ComposedModel.new
|
260
|
+
def model.persisted?; true; end
|
336
261
|
|
337
|
-
model.save
|
338
|
-
|
339
|
-
#
|
340
|
-
#
|
341
|
-
#
|
342
|
-
# after_save called!
|
262
|
+
model.save
|
263
|
+
# => before_save
|
264
|
+
# => before_update
|
265
|
+
# => after_update
|
266
|
+
# => after_save
|
343
267
|
```
|
344
268
|
|
345
|
-
###
|
346
|
-
|
347
|
-
The interface remains consistent with standard ActiveModel and ActiveRecord models, so the :context option works with #save.
|
348
|
-
|
349
|
-
```ruby
|
350
|
-
composed_model.valid?(:custom_context)
|
351
|
-
|
352
|
-
composed_model.save(context: :custom_context)
|
353
|
-
```
|
269
|
+
### Notes on adding models dynamically
|
354
270
|
|
355
|
-
|
356
|
-
|
271
|
+
Avoid adding `models` to the models array **after validation has already run**
|
272
|
+
(for example, inside `after_validation` or `before_save` callbacks).
|
357
273
|
|
358
274
|
```ruby
|
359
|
-
class
|
360
|
-
|
361
|
-
validates :email, presence: true
|
362
|
-
validates :email, format: { with: /\.edu\z/ }, on: :education
|
275
|
+
class Example < ActiveRecordCompose::Model
|
276
|
+
before_save { models << AnotherModel.new }
|
363
277
|
end
|
278
|
+
```
|
364
279
|
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
super(attributes)
|
369
|
-
end
|
370
|
-
|
371
|
-
attribute :accept, :boolean
|
372
|
-
validates :accept, presence: true, on: :education
|
280
|
+
In this case, the newly added model will **not** run validations for the current save cycle.
|
281
|
+
This may look like a bug, but it is the expected behavior: validations are only applied
|
282
|
+
to models that were registered before validation started.
|
373
283
|
|
374
|
-
|
375
|
-
|
376
|
-
|
284
|
+
We intentionally do not restrict this at the framework level, since there may be valid
|
285
|
+
advanced use cases where models are manipulated dynamically.
|
286
|
+
Instead, this behavior is documented here so that developers can make an informed decision.
|
377
287
|
|
378
|
-
|
379
|
-
end
|
380
|
-
```
|
381
|
-
```ruby
|
382
|
-
r = Registration.new(name: 'foo', email: 'example@example.com', accept: false)
|
383
|
-
r.valid?
|
384
|
-
#=> true
|
385
|
-
|
386
|
-
r.valid?(:education)
|
387
|
-
#=> false
|
388
|
-
r.errors.map { [_1.attribute, _1.type] }
|
389
|
-
#=> [[:email, :invalid], [:accept, :blank]]
|
390
|
-
|
391
|
-
r.email = 'example@example.edu'
|
392
|
-
r.accept = true
|
393
|
-
|
394
|
-
r.valid?(:education)
|
395
|
-
#=> true
|
396
|
-
r.save(context: :education)
|
397
|
-
#=> true
|
398
|
-
```
|
288
|
+
## Sample Application
|
399
289
|
|
400
|
-
|
290
|
+
The sample app demonstrates a more complete usage of ActiveRecordCompose
|
291
|
+
(e.g., user registration flows involving multiple models).
|
292
|
+
It is not meant to cover every possible pattern, but can serve as a reference
|
293
|
+
for putting the library into practice.
|
401
294
|
|
402
|
-
|
295
|
+
Try it out in your browser with GitHub Codespaces (or locally):
|
403
296
|
|
404
297
|
- https://github.com/hamajyotan/active_record_compose-example
|
405
298
|
|
406
299
|
## Links
|
407
300
|
|
408
|
-
- [
|
409
|
-
- [
|
301
|
+
- [API Documentation (YARD)](https://hamajyotan.github.io/active_record_compose/)
|
302
|
+
- [Blog article introducing the concept](https://dev.to/hamajyotan/smart-way-to-update-multiple-models-simultaneously-in-rails-51b6)
|
410
303
|
|
411
304
|
## Development
|
412
305
|
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_record_compose
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 1.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- hamajyotan
|
@@ -15,7 +15,7 @@ dependencies:
|
|
15
15
|
requirements:
|
16
16
|
- - ">="
|
17
17
|
- !ruby/object:Gem::Version
|
18
|
-
version: '7.
|
18
|
+
version: '7.1'
|
19
19
|
- - "<"
|
20
20
|
- !ruby/object:Gem::Version
|
21
21
|
version: '8.1'
|
@@ -25,7 +25,7 @@ dependencies:
|
|
25
25
|
requirements:
|
26
26
|
- - ">="
|
27
27
|
- !ruby/object:Gem::Version
|
28
|
-
version: '7.
|
28
|
+
version: '7.1'
|
29
29
|
- - "<"
|
30
30
|
- !ruby/object:Gem::Version
|
31
31
|
version: '8.1'
|
@@ -81,7 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
81
81
|
- !ruby/object:Gem::Version
|
82
82
|
version: '0'
|
83
83
|
requirements: []
|
84
|
-
rubygems_version: 3.
|
84
|
+
rubygems_version: 3.7.2
|
85
85
|
specification_version: 4
|
86
86
|
summary: activemodel form object pattern
|
87
87
|
test_files: []
|