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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 954ce34124a1c8e83a45710e104dd965c407afb5ba296b74d4a4e5f9d13d971a
4
- data.tar.gz: c1bb35ef5458a804f243b886c037aff3794822477ea2aaf5a57bfb12a4001477
3
+ metadata.gz: 9cdfb23b1af43681e6c4af3e9fed615bc3dc64873461bdee6f49c92f706f33e9
4
+ data.tar.gz: 4530c0987f038605cb6dc3da229df21e8cf16af29d6ca8cf3078b1dc933cc838
5
5
  SHA512:
6
- metadata.gz: 2e7f81f304cec10420cb4f405ad8ad7f93f3811c7f43bf9952c05dbca84e651bbf0c851e648349d7e93986cf67cb177110785588a47c8951718f4d271cf53944
7
- data.tar.gz: 8ac490bd69c0d51e6e89f1f198a4e052eccbeedcf1b234edfa8c9635819fe336bbe10b6b64b6b55907524543ad34eb9343892b00f54f2737a65c45c120456afa
6
+ metadata.gz: 10c37b41ff66acd993e2f5c412ed5f48213f29344a94e0ab27d40766e11f60197808d4d1b9e97118f729b4be1d272a4bdae100c8e06489c40008ac3c92f6fe61
7
+ data.tar.gz: 67c4086fce4661e89188161fd6d23610bca3dcb9ad8a6e781162909d07fbf750add3ad804d8e58bd92bf4fc1305bcebd2452a4a9d924af70e56e6a0217ecf70f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.0.0] - 2025-09-23
4
+
5
+ - drop support rails 7.0.x
6
+
3
7
  ## [0.12.0] - 2025-08-21
4
8
 
5
9
  - Omits default arguments for `#update` and `#update!`. It's to align I/F with ActiveRecord.
data/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # ActiveRecordCompose
2
2
 
3
- activemodel (activerecord) form object pattern. it embraces multiple AR models and provides a transparent interface as if they were a single model.
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
  [![Gem Version](https://badge.fury.io/rb/active_record_compose.svg)](https://badge.fury.io/rb/active_record_compose)
6
7
  ![CI](https://github.com/hamajyotan/active_record_compose/workflows/CI/badge.svg)
@@ -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
- - [Usage](#usage)
14
- - [Basic usage](#basic-usage)
15
- - [`delegate_attribute`](#delegate_attribute)
16
- - [Promotion to model from AR-model errors](#promotion-to-model-from-ar-model-errors)
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
- - [`destroy` option](#destroy-option)
20
- - [Callback ordering by `#persisted?`](#callback-ordering-by-persisted)
21
- - [`#save` with custom context option](#save-with-custom-context-option)
22
- - [Sample application as an example](#sample-application-as-an-example)
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, and by defining validations and callbacks, it allows you to structure your use cases effectively. This is a crucial component of Rails. However, when a specific model starts being updated by multiple different use cases, validations and callbacks may require conditions such as `on: :context` or `save(validate: false)`. As a result, the model needs to account for multiple dependent use cases, leading to increased complexity.
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
- In such cases, `ActiveModel::Model` becomes useful. It provides the same interfaces as `ActiveRecord::Base`, such as `attribute` and `errors`, allowing it to be used similarly to an ActiveRecord model. Additionally, it enables you to define validations and callbacks within a limited context, preventing conditions related to multiple contexts from being embedded in `ActiveRecord::Base` validations and callbacks. This results in simpler, more maintainable code.
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
- This gem is built on `ActiveModel::Model` and acts as a first-class model within the Rails context. It provides methods for performing batch and safe updates on 0..N encapsulated models, enables transparent attribute access, and facilitates access to error information.
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
- ## Usage
61
+ ## Quick Start
52
62
 
53
- ### Basic usage
63
+ ### Basic Example
54
64
 
55
- (Below, it is assumed that there are two AR model definitions, `Account` and `Profile`, for the sake of explanation.)
65
+ Suppose you have two models:
56
66
 
57
67
  ```ruby
58
68
  class Account < ApplicationRecord
59
- has_one :profile # can work without `autosave:true`
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
- Here is an example of designing a model that updates both Account and Profile at the same time, using `ActiveRecordCompose::Model`.
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
- The above model is used as follows.
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
- By executing `save`, you can simultaneously update multiple models added to `models`. Furthermore, the save operation is performed within a database transaction, ensuring atomic processing.
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
- ```ruby
136
- user_registration.save # Atomically update Account and Profile.
137
- # In case of failure, a false value is returned.
138
- user_registration.save! # With the bang method,
139
- # an exception is raised in case of failure.
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
- ### `delegate_attribute`
144
+ Both `Account` and `Profile` will be updated **atomically in one transaction**.
145
+
146
+ ### Attribute Delegation
143
147
 
144
- In many cases, the composed models have attributes that need to be assigned before saving. `ActiveRecordCompose::Model` provides `delegate_attribute`, allowing transparent access to those attributes."
148
+ `delegate_attribute` allows transparent access to attributes of inner models:
145
149
 
146
150
  ```ruby
147
- # UserRegistration behaves as if it has attributes like email, name, and age
148
- # For example, `email` is delegated to `account.email`,
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
- Attributes defined with `.delegate_attribute` can be accessed through `#attributes` in the same way as the original attributes defined with `.attribute`.
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
- ### Promotion to model from AR-model errors
169
+ ### Unified Error Handling
175
170
 
176
- When saving a composed model with `#save`, models that are not valid with `#valid?` will obviously not be saved. As a result, the #errors information can be accessed from `ActiveRecordCompose::Model`.
171
+ Validation errors from inner models are collected into the composed model:
177
172
 
178
173
  ```ruby
179
- user_registration = UserRegistration.new
180
- user_registration.email = "foo@example.com"
181
- user_registration.email_confirmation = "BAZ@example.com"
182
- user_registration.age = 18
183
- user_registration.terms_of_service = true
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.to_a
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 the `#save!` operation raises an `ActiveRecord::RecordInvalid` exception, it is necessary to have pre-existing locale definitions in order to construct i18n information correctly.
200
- The specific keys required are `activemodel.errors.messages.record_invalid` or `errors.messages.record_invalid`.
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
- Alternatively, the following definition is also acceptable:
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
- ### `destroy` option
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
- account = Account.last
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
- Conditional destroy (or save) can be written like this.
215
+ This deletes the model on `#save` instead of persisting it.
216
+ Conditional deletion is also supported:
263
217
 
264
218
  ```ruby
265
- class AccountRegistration < ActiveRecordCompose::Model
266
- def initialize(account)
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
- delegate_attribute :email, to: :account
279
- delegate_attribute :name, :age, to: :profile
222
+ ### Callback ordering with `#persisted?`
280
223
 
281
- private
224
+ The result of `#persisted?` determines **which callbacks are fired**:
282
225
 
283
- attr_reader :account, :profile
226
+ - `persisted? == false` -> create callbacks (`before_create`, `after_create`, ...)
227
+ - `persisted? == true` -> update callbacks (`before_update`, `after_update`, ...)
284
228
 
285
- def profile_field_is_blank?
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
- before_save { puts 'before_save called!' }
301
- before_create { puts 'before_create called!' }
302
- before_update { puts 'before_update called!' }
303
- after_save { puts 'after_save called!' }
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
- # Override and return a boolish value depending on the state of the inner model.
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
- ```ruby
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 # or `model.update` (the same callbacks will be triggered in all cases).
325
-
326
- # before_save called!
327
- # before_update called! # when persisted? is false, before_create hook is fired here instead.
328
- # after_update called! # when persisted? is false, after_create hook is fired here instead.
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 # or `model.update` (the same callbacks will be triggered in all cases).
338
-
339
- # before_save called!
340
- # before_create called!
341
- # after_create called!
342
- # after_save called!
262
+ model.save
263
+ # => before_save
264
+ # => before_update
265
+ # => after_update
266
+ # => after_save
343
267
  ```
344
268
 
345
- ### `#save` with custom context option
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
- However, this may not be ideal from a design perspective.
356
- If your application requires complex context-specific validations, consider separating models by context.
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 Account < ActiveRecord::Base
360
- validates :name, presence: true
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
- class Registration < ActiveRecordCompose::Model
366
- def initialize(attributes = {})
367
- models.push(@account = Account.new)
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
- delegate_attribute :name, :email, to: :account
375
-
376
- private
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
- attr_reader :account
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
- ## Sample application as an example
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
- With Github Codespaces, it can also be run directly in the browser. Naturally, a local environment is also possible.
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
- - [Document from YARD](https://hamajyotan.github.io/active_record_compose/)
409
- - [Smart way to update multiple models simultaneously in Rails](https://dev.to/hamajyotan/smart-way-to-update-multiple-models-simultaneously-in-rails-51b6)
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordCompose
4
- VERSION = "0.12.0"
4
+ VERSION = "1.0.0"
5
5
  end
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.12.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.0'
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.0'
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.6.7
84
+ rubygems_version: 3.7.2
85
85
  specification_version: 4
86
86
  summary: activemodel form object pattern
87
87
  test_files: []