active_record_compose 0.6.2 → 0.7.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: 88e574f327847973e3b931ee6663de0d395fa6ff26de5830f92ac21d1445fe20
4
- data.tar.gz: bb50aa19e200f15c3b16d6b7fe65dc4e94afc7e44eec3c94ef37ab18fc610b88
3
+ metadata.gz: ab0bb2d4ff8813ad8fb9a4830c2b1d3887aaa29702f11561c9c8624719a5082f
4
+ data.tar.gz: a3fcfb870aaf5a0825a218556423cc1af16644b41ec22f0b4d656cb42cd33790
5
5
  SHA512:
6
- metadata.gz: 77a492aea1e8c5aec20bea84dbbd242c2f94d9d5674a705cacea4ad4b8b3991b54630009fb78ed425a6dee5f10d28dd5f473b7b581a92711701de07472d7a88f
7
- data.tar.gz: a8386a77e58cb7db6c2478137af7bf24e606448987009257b151c695b746be9818de2d963b65672cc60e23b9a8dc7d0b29790087ea3a8271152c0115d2212bdf
6
+ metadata.gz: d5a1ea42bd83a986437faac38b2543ee48aff7f2003afa6267a7adcd325db1bbb0506f4f1f25e2aaa13b733df7956ebd820627821f19101fc36dd4f046a51c43
7
+ data.tar.gz: 4a9cc98fddbcf1068720387207469cb527b96e536a9f109a6174b5a439daa383f54a1625e916c731d01883cf360c6e01281858a238796b5cb64b92665c2405c3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2025-02-12
4
+
5
+ - rename ActiveRecordCompose::InnerModel to ActiveRecordCompose::WrappedModel
6
+ - rename ActiveRecordCompose::InnerModelCollection to ActiveRecordCompose::ComposedCollection
7
+ - A new callback control flag, `persisted_flag_callback_control`, has been defined.
8
+ Currently, the default value is false, which does not change the existing behavior, but it will be deprecated in the future.
9
+ When the flag is set to true, the behavior will be almost the same as the callback sequence in ActiveRecord.
10
+ (https://github.com/hamajyotan/active_record_compose/issues/11)
11
+
12
+ ## [0.6.3] - 2025-01-31
13
+
14
+ - fix: type error in `ActiveRecordCompose::Model` subclass definitions.
15
+ - fixed type errors in subclass callback definitions, etc.
16
+ - doc: more detailed gem desciption.
17
+ - rewrite readme.
18
+
3
19
  ## [0.6.2] - 2025-01-04
4
20
 
5
21
  - fix: `delegate_attribute` defined in a subclass had an unintended side effect on the superclass.
data/README.md CHANGED
@@ -1,9 +1,35 @@
1
1
  # ActiveRecordCompose
2
2
 
3
- activermodel (activerecord) form object pattern.
3
+ activemodel (activerecord) form object pattern. it embraces multiple AR models and provides a transparent interface as if they were a single model.
4
4
 
5
5
  ![CI](https://github.com/hamajyotan/active_record_compose/workflows/CI/badge.svg)
6
6
 
7
+ ## Table of Contents
8
+
9
+ - [Motivation](#motivation)
10
+ - [Installation](#installation)
11
+ - [Usage](#usage)
12
+ - [Basic usage](#basic-usage)
13
+ - [`delegate_attribute`](#delegate_attribute)
14
+ - [Promotion to model from AR-model errors](#promotion-to-model-from-ar-model-errors)
15
+ - [I18n](#i18n)
16
+ - [Advanced Usage](#advanced-usage)
17
+ - [`destroy` option](#destroy-option)
18
+ - [Callback ordering by `#save`, `#create` and `#update`](#callback-ordering-by-save-create-and-update)
19
+ - [Links](#links)
20
+ - [Development](#development)
21
+ - [Contributing](#contributing)
22
+ - [License](#license)
23
+ - [Code of Conduct](#code-of-conduct)
24
+
25
+ ## Motivation
26
+
27
+ `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.
28
+
29
+ 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.
30
+
31
+ 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.
32
+
7
33
  ## Installation
8
34
 
9
35
  To install `active_record_compose`, just put this line in your Gemfile:
@@ -20,149 +46,186 @@ $ bundle
20
46
 
21
47
  ## Usage
22
48
 
23
- ### ActiveRecordCompose::Model basic
49
+ ### Basic usage
24
50
 
25
- It wraps AR objects or equivalent models to provide unified operation.
26
- For example, the following cases are supported
51
+ (Below, it is assumed that there are two AR model definitions, `Account` and `Profile`, for the sake of explanation.)
27
52
 
28
- #### Context-specific callbacks.
53
+ ```ruby
54
+ class Account < ApplicationRecord
55
+ has_one :profile # can work without `autosave:true`
56
+ validates :name, :email, presence: true
57
+ end
58
+
59
+ class Profile < ApplicationRecord
60
+ belongs_to :account
61
+ validates :firstname, :lastname, :age, presence: true
62
+ end
63
+ ```
29
64
 
30
- A callback is useful to define some processing before or after a save in a particular model.
31
- However, if a callback is written directly in the AR model, it is necessary to consider the case where the model is updated in other contexts.
32
- In particular, if you frequently create with test data, previously unnecessary processing will be called at every point of creation.
33
- In addition to cost, the more complicated the callbacks you write, the more difficult it will be to create even a single test data.
34
- If the callbacks are written in a class that inherits from `ActiveRecordCompose::Model`, the AR model itself will not be polluted, and the context can be limited.
65
+ Here is an example of designing a model that updates both Account and Profile at the same time, using `ActiveRecordCompose::Model`.
35
66
 
36
67
  ```ruby
37
- class AccountRegistration < ActiveRecordCompose::Model
38
- def initialize(account = Account.new, attributes = {})
39
- @account = account
40
- super(attributes) # When overrides `#initialize`, be sure to call `super`.
68
+ class UserRegistration < ActiveRecordCompose::Model
69
+ def initialize
70
+ @account = Account.new
71
+ @profile = @account.build_profile
41
72
 
42
- # By including AR instance in models, AR instance itself is saved when this model is saved.
43
- models.push(account)
73
+ super() # Don't forget to call `super()`
74
+ # RuboCop's Lint/MissingSuper cop assists in addressing this.
75
+
76
+ models << account << profile
77
+ # Alternatively, it can also be written as follows:
78
+ # models.push(account)
79
+ # models.push(profile)
44
80
  end
45
81
 
46
- # By delegating these to the AR instance,
47
- # For example, this model itself can be given directly as an argument to form_with, and it will behave as if it were an instance of the model.
48
- delegate :id, :persisted?, to: :account
82
+ # Attribute declarations using ActiveModel::Attributes are supported.
83
+ attribute :terms_of_service, :boolean
49
84
 
50
- # Defines an attribute of the same name that delegates to account#name and account#email
51
- delegate_attribute :name, :email, to: :account
85
+ # You can provide validation definitions limited to UserRegistration.
86
+ # Instead of directly defining validations for Account or Profile, such
87
+ # as `on: :create` in the context, the model itself explains the context.
88
+ validates :terms_of_service, presence: true
89
+ validates :email, confirmation: true
90
+
91
+ # You can provide callback definitions limited to UserRegistration.
92
+ # For example, if this is written directly in the AR model, you need to consider
93
+ # callback control for data generation during tests and other situations.
94
+ after_commit :send_email_message
52
95
 
53
- # You can only define post-processing if you update through this model.
54
- # If this is written directly into the AR model, for example, it would be necessary to consider a callback control for each test data generation.
55
- after_commit :try_send_email_message
96
+ # UserRegistration behaves as if it has attributes like email, name, and age
97
+ # For example, `email` is delegated to `account.email`,
98
+ # and `email=` is delegated to `account.email=`.
99
+ delegate_attribute :name, :email, to: :account
100
+ delegate_attribute :firstname, :lastname, :age, to: :profile
56
101
 
57
102
  private
58
103
 
59
- attr_reader :account
104
+ attr_reader :account, :profile
60
105
 
61
- def try_send_email_message
106
+ def send_email_message
62
107
  SendEmailConfirmationJob.perform_later(account)
63
108
  end
64
109
  end
65
110
  ```
66
111
 
67
- #### Validation limited to a specific context.
68
-
69
- Validates are basically fired in all cases where the model is manipulated. To avoid this, use `on: :create`, etc. to make it work only in specific cases.
70
- and so on to work only in specific cases. This allows you to create context-sensitive validations for the same model operation.
71
- However, this is the first step in making the model more and more complex. You will have to go around with `update(context: :foo)`
72
- In some cases, you may have to go around with the context option, such as `update(context: :foo)` everywhere.
73
- By writing validates in a class that extends `ActiveRecordCompose::Model`, you can define context-specific validation without polluting the AR model itself.
112
+ The above model is used as follows.
74
113
 
75
114
  ```ruby
76
- class AccountRegistration < ActiveRecordCompose::Model
77
- def initialize(account = Account.new, attributes = {})
78
- @account = account
79
- super(attributes)
80
- models.push(account)
81
- end
115
+ registration = UserRegistration.new
116
+
117
+ # Atomically update Account and Profile.
118
+ registration.update!(
119
+ name: "foo",
120
+ email: "bar@example.com",
121
+ firstname: "taro",
122
+ lastname: "yamada",
123
+ age: 18,
124
+ email_confirmation: "bar@example.com",
125
+ terms_of_service: true,
126
+ )
127
+ ```
82
128
 
83
- delegate :id, :persisted?, to: :account
84
- delegate_attribute :name, :email, to: :account
129
+ 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.
85
130
 
86
- # Only if this model is used, also check the validity of the domain
87
- before_validation :require_valid_domain
131
+ ```ruby
132
+ user_registration.save # Atomically update Account and Profile.
133
+ # In case of failure, a false value is returned.
134
+ user_registration.save! # With the bang method,
135
+ # an exception is raised in case of failure.
136
+ ```
88
137
 
89
- private
138
+ ### `delegate_attribute`
90
139
 
91
- attr_reader :account
92
-
93
- # Validity of the domain part of the e-mail address is also checked only when registering an account.
94
- def require_valid_domain
95
- e = ValidEmail2::Address.new(email.to_s)
96
- unless e.valid?
97
- errors.add(:email, :invalid_format)
98
- return
99
- end
100
- unless e.valid_mx?
101
- errors.add(:email, :invalid_domain)
102
- end
103
- end
104
- end
105
- ```
140
+ 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."
106
141
 
107
142
  ```ruby
108
- account = Account.new(name: 'new account', email: 'foo@example.com')
109
- account.valid? #=> true
143
+ # UserRegistration behaves as if it has attributes like email, name, and age
144
+ # For example, `email` is delegated to `account.email`,
145
+ # and `email=` is delegated to `account.email=`.
146
+ delegate_attribute :name, :email, to: :account
147
+ delegate_attribute :firstname, :lastname, :age, to: :profile
148
+ ```
149
+
150
+ Attributes defined with `.delegate_attribute` can be accessed through `#attributes` in the same way as the original attributes defined with `.attribute`.
110
151
 
111
- account_registration = AccountRegistration.new(name: 'new account', email: 'foo@example.com')
112
- account_registration.valid? #=> false
152
+ ```ruby
153
+ registration = UserRegistration.new
154
+ registration.name = "foo"
155
+ registration.terms_of_service = true
156
+
157
+ # Not only the email_confirmation defined with attribute,
158
+ # but also the attributes defined with delegate_attribute are included.
159
+ registration.attributes
160
+ # => {
161
+ # "terms_of_service" => true,
162
+ # "email" => nil,
163
+ # "name" => "foo",
164
+ # "age" => nil,
165
+ # "firstname" => nil,
166
+ # "lastname" => nil
167
+ # }
113
168
  ```
114
169
 
115
- #### updating multiple models at the same time.
170
+ ### Promotion to model from AR-model errors
116
171
 
117
- In an AR model, you can add, for example, `autosave: true` or `accepts_nested_attributes_for` to an association to update the related models at the same time.
118
- There are ways to update related models at the same time. The operation is safe because it is transactional.
119
- `ActiveRecordCompose::Model` has an internal array called models. By adding an AR object to this models array
120
- By adding an AR object to the models, the object stored in the models provides an atomic update operation via #save.
172
+ 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`.
121
173
 
122
174
  ```ruby
123
- class AccountRegistration < ActiveRecordCompose::Model
124
- def initialize(account = Account.new, profile = account.build_profile, attributes = {})
125
- @account = account
126
- @profile = profile
127
- super(attributes)
128
- models << account << profile
129
- end
175
+ user_registration = UserRegistration.new
176
+ user_registration.email = "foo@example.com"
177
+ user_registration.email_confirmation = "BAZ@example.com"
178
+ user_registration.age = 18
179
+ user_registration.terms_of_service = true
180
+
181
+ user_registration.save
182
+ #=> false
183
+
184
+ user_registration.errors.to_a
185
+ # => [
186
+ # "Name can't be blank",
187
+ # "Firstname can't be blank",
188
+ # "Lastname can't be blank",
189
+ # "Email confirmation doesn't match Email"
190
+ # ]
191
+ ```
130
192
 
131
- delegate :id, :persisted?, to: :account
132
- delegate_attribute :name, :email, to: :account
133
- delegate_attribute :firstname, :lastname, :age, to: :profile
193
+ ### I18n
134
194
 
135
- private
195
+ 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.
196
+ The specific keys required are `activemodel.errors.messages.record_invalid` or `errors.messages.record_invalid`.
136
197
 
137
- attr_reader :account, :profile
138
- end
198
+ (Replace `en` as appropriate in the context.)
199
+
200
+ ```yaml
201
+ en:
202
+ activemodel:
203
+ errors:
204
+ messages:
205
+ record_invalid: 'Validation failed: %{errors}'
139
206
  ```
140
207
 
141
- ```ruby
142
- Account.count #=> 0
143
- Profile.count #=> 0
144
-
145
- account_registration =
146
- AccountRegistration.new(
147
- name: 'foo',
148
- email: 'foo@example.com',
149
- firstname: 'bar',
150
- lastname: 'baz',
151
- age: 36,
152
- )
153
- account_registration.save!
154
-
155
- Account.count #=> 1
156
- Profile.count #=> 1
208
+ Alternatively, the following definition is also acceptable:
209
+
210
+ ```yaml
211
+ en:
212
+ errors:
213
+ messages:
214
+ record_invalid: 'Validation failed: %{errors}'
157
215
  ```
158
216
 
159
- 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.
217
+ ## Advanced Usage
218
+
219
+ ### `destroy` option
220
+
221
+ 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.
160
222
 
161
223
  ```ruby
162
224
  class AccountResignation < ActiveRecordCompose::Model
163
225
  def initialize(account)
164
226
  @account = account
165
- @profile = account.profile # Suppose that Account has_one Profile.
227
+ @profile = account.profile || account.build_profile
228
+ super()
166
229
  models.push(account)
167
230
  models.push(profile, destroy: true)
168
231
  end
@@ -178,7 +241,6 @@ class AccountResignation < ActiveRecordCompose::Model
178
241
  end
179
242
  end
180
243
  ```
181
-
182
244
  ```ruby
183
245
  account = Account.last
184
246
 
@@ -197,72 +259,44 @@ Conditional destroy (or save) can be written like this.
197
259
 
198
260
  ```ruby
199
261
  class AccountRegistration < ActiveRecordCompose::Model
200
- def initialize(account, attributes = {})
262
+ def initialize(account)
201
263
  @account = account
202
264
  @profile = account.profile || account.build_profile
203
- super(attributes)
265
+ super()
204
266
  models.push(account)
205
- models.push(profile, destroy: :all_blank?) # destroy if all blank, otherwise save.
267
+
268
+ # destroy if all blank, otherwise save.
269
+ models.push(profile, destroy: :profile_field_is_blank?)
270
+ # Alternatively, it can also be written as follows:
271
+ # models.push(profile, destroy: -> { profile_field_is_blank? })
206
272
  end
207
273
 
208
- delegate_attribute :name, :email, to: :account
209
- delegate_attribute :firstname, :lastname, :age, to: :profile
274
+ delegate_attribute :email, to: :account
275
+ delegate_attribute :name, :age, to: :profile
210
276
 
211
277
  private
212
278
 
213
279
  attr_reader :account, :profile
214
280
 
215
- def all_blank? = firstname.blank && lastname.blank? && age.blank?
216
- end
217
- ```
218
-
219
- ### `delegate_attribute`
220
-
221
- It provides a macro description that expresses access to the attributes of the AR model through delegation.
222
-
223
- ```ruby
224
- class AccountRegistration < ActiveRecordCompose::Model
225
- def initialize(account, attributes = {})
226
- @account = account
227
- super(attributes)
228
- models.push(account)
281
+ def profile_field_is_blank?
282
+ firstname.blank? && lastname.blank? && age.blank?
229
283
  end
230
-
231
- attribute :original_attribute, :string, default: 'qux'
232
- delegate_attribute :name, to: :account
233
-
234
- private
235
-
236
- attr_reader :account
237
284
  end
238
285
  ```
239
286
 
240
- ```ruby
241
- account = Account.new
242
- account.name = 'foo'
243
-
244
- registration = AccountRegistration.new(account)
245
- registration.name #=> 'foo'
246
-
247
- registration.name = 'bar'
248
- account.name #=> 'bar'
249
- ```
250
-
251
- Overrides `#attributes`, merging attributes defined with `delegate_attribute` in addition to the original attributes.
252
-
253
- ```
254
- account.attributes #=> {'original_attribute' => 'qux', 'name' => 'bar'}
255
- ```
256
-
257
287
  ### Callback ordering by `#save`, `#create` and `#update`.
258
288
 
259
- Sometimes, multiple AR objects are passed to the models in the arguments.
260
- It is not strictly possible to distinguish between create and update operations, regardless of the state of `#persisted?`.
261
- Therefore, control measures such as separating callbacks with `after_create` and `after_update` based on the `#persisted?` of AR objects are left to the discretion of the user,
262
- rather than being determined by the state of the AR objects themselves.
289
+ The behavior of `(before|after|around)_create` and `(before|after|around)_update` hooks depends on
290
+ the state of the `persisted_flag_callback_control` setting.
291
+
292
+ When `persisted_flag_callback_control` is set to false,
293
+ the execution of `#create`, `#update`, or `#save` determines which callbacks will be triggered.
294
+ Currently, the default value is `false`, but it will no longer be supported in the future.
263
295
 
264
296
  ```ruby
265
297
  class ComposedModel < ActiveRecordCompose::Model
298
+ self.persisted_flag_callback_control = false # Currently defaults to false, but will no longer be supported in the future.
299
+
266
300
  # ...
267
301
 
268
302
  before_save { puts 'before_save called!' }
@@ -294,30 +328,70 @@ model.update
294
328
  # after_save called!
295
329
  ```
296
330
 
297
- ### I18n
331
+ When `persisted_flag_callback_control` is set to `true`, it behaves almost like callback control in ActiveRecord.
332
+ This behavior will be the default in the future.
298
333
 
299
- 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.
300
- The specific keys required are `activemodel.errors.messages.record_invalid` or `errors.messages.record_invalid`.
334
+ ```ruby
335
+ class ComposedModel < ActiveRecordCompose::Model
336
+ self.persisted_flag_callback_control = true # In the future, true will be the default and false will no longer be supported.
301
337
 
302
- (Replace `en` as appropriate in the context.)
338
+ # ...
303
339
 
304
- ```yaml
305
- en:
306
- activemodel:
307
- errors:
308
- messages:
309
- record_invalid: 'Validation failed: %{errors}'
340
+ before_save { puts 'before_save called!' }
341
+ before_create { puts 'before_create called!' }
342
+ before_update { puts 'before_update called!' }
343
+ after_save { puts 'after_save called!' }
344
+ after_create { puts 'after_create called!' }
345
+ after_update { puts 'after_update called!' }
346
+
347
+ def persisted?
348
+ # Override and return a boolish value depending on the state of the inner model.
349
+ # For example, it could be transferred to the primary model to be manipulated.
350
+ #
351
+ # # ex.)
352
+ # def persisted? = the_model.persisted?
353
+ #
354
+ true
355
+ end
356
+ end
310
357
  ```
311
358
 
312
- Alternatively, the following definition is also acceptable:
359
+ ```ruby
360
+ # when `model.persisted?` returns `true`
313
361
 
314
- ```yaml
315
- en:
316
- errors:
317
- messages:
318
- record_invalid: 'Validation failed: %{errors}'
362
+ model = ComposedModel.new
363
+
364
+ model.save # or `model.update` (the same callbacks will be triggered in all cases).
365
+
366
+ # before_save called!
367
+ # before_update called! # when persisted? is false, before_create hook is fired here instead.
368
+ # after_update called! # when persisted? is false, after_create hook is fired here instead.
369
+ # after_save called!
319
370
  ```
320
371
 
372
+ ```ruby
373
+ # when `model.persisted?` returns `false`
374
+
375
+ model = ComposedModel.new
376
+
377
+ model.save # or `model.update` (the same callbacks will be triggered in all cases).
378
+
379
+ # before_save called!
380
+ # before_create called!
381
+ # after_create called!
382
+ # after_save called!
383
+ ```
384
+
385
+ When `persisted_flag_callback_control` is `true`, `#create` is not supported.
386
+
387
+ ```ruby
388
+ model.create # => raises RuntimeError
389
+ ```
390
+
391
+ ## Links
392
+
393
+ - [Smart way to update multiple models simultaneously in Rails](https://dev.to/hamajyotan/smart-way-to-update-multiple-models-simultaneously-in-rails-51b6)
394
+
321
395
  ## Development
322
396
 
323
397
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -335,3 +409,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
335
409
  ## Code of Conduct
336
410
 
337
411
  Everyone interacting in the ActiveRecord::Compose project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/hamajyotan/active_record_compose/blob/main/CODE_OF_CONDUCT.md).
412
+
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_record_compose/inner_model'
3
+ require 'active_record_compose/wrapped_model'
4
4
 
5
5
  module ActiveRecordCompose
6
- using InnerModel::PackagePrivate
6
+ using WrappedModel::PackagePrivate
7
7
 
8
- class InnerModelCollection
8
+ class ComposedCollection
9
9
  include Enumerable
10
10
 
11
11
  def initialize(owner)
@@ -79,30 +79,25 @@ module ActiveRecordCompose
79
79
  attr_reader :owner, :models
80
80
 
81
81
  def wrap(model, destroy: false, if: nil)
82
- if model.is_a?(ActiveRecordCompose::InnerModel)
83
- # @type var model: ActiveRecordCompose::InnerModel
84
- model
85
- else
86
- if destroy.is_a?(Symbol)
87
- method = destroy
88
- destroy = -> { owner.__send__(method) }
89
- end
90
- if_option = binding.local_variable_get(:if)
91
- if if_option.is_a?(Symbol)
92
- method = if_option
93
- if_option = -> { owner.__send__(method) }
94
- end
95
- ActiveRecordCompose::InnerModel.new(model, destroy:, if: if_option)
82
+ if destroy.is_a?(Symbol)
83
+ method = destroy
84
+ destroy = -> { owner.__send__(method) }
96
85
  end
86
+ if_option = binding.local_variable_get(:if)
87
+ if if_option.is_a?(Symbol)
88
+ method = if_option
89
+ if_option = -> { owner.__send__(method) }
90
+ end
91
+ ActiveRecordCompose::WrappedModel.new(model, destroy:, if: if_option)
97
92
  end
98
93
 
99
94
  # @private
100
95
  module PackagePrivate
101
- refine InnerModelCollection do
96
+ refine ComposedCollection do
102
97
  # Returns array of wrapped model instance.
103
98
  #
104
99
  # @private
105
- # @return [Array[InnerModel] array of wrapped model instance.
100
+ # @return [Array[WrappedModel]] array of wrapped model instance.
106
101
  def __wrapped_models = models.reject { _1.ignore? }.select { _1.__raw_model }
107
102
  end
108
103
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_record_compose/composed_collection'
3
4
  require 'active_record_compose/delegate_attribute'
4
- require 'active_record_compose/inner_model_collection'
5
5
  require 'active_record_compose/transaction_support'
6
6
 
7
7
  module ActiveRecordCompose
8
- using InnerModelCollection::PackagePrivate
8
+ using ComposedCollection::PackagePrivate
9
9
 
10
10
  class Model
11
11
  include ActiveModel::Model
@@ -15,6 +15,46 @@ module ActiveRecordCompose
15
15
  include ActiveRecordCompose::DelegateAttribute
16
16
  include ActiveRecordCompose::TransactionSupport
17
17
 
18
+ # This flag controls the callback sequence for models.
19
+ # The current default value is `false`, but support for `false` is planned to be discontinued in the future.
20
+ #
21
+ # When `persisted_flag_callback_control` is set to `true`,
22
+ # the occurrence of callbacks depends on the evaluation result of `#persisted?`.
23
+ # Additionally, the definition of `#persisted?` itself can be appropriately overridden in subclasses.
24
+ #
25
+ # if `#persisted?` returns `false`:
26
+ # * before_save
27
+ # * before_create
28
+ # * after_create
29
+ # * after_save
30
+ #
31
+ # if `#persisted?` returns `true`:
32
+ # * before_save
33
+ # * before_update
34
+ # * after_update
35
+ # * after_save
36
+ #
37
+ # On the other hand, when `persisted_flag_callback_control` is set to `false`,
38
+ # the invoked methods during saving operations vary depending on the method used.
39
+ #
40
+ # when performing `#save` or `#save!`:
41
+ # * before_save
42
+ # * after_save
43
+ #
44
+ # when performing `#update` or `#update!`:
45
+ # * before_save
46
+ # * before_update
47
+ # * after_update
48
+ # * after_save
49
+ #
50
+ # when performing `#create` or `#create!`:
51
+ # * before_save
52
+ # * before_create
53
+ # * after_create
54
+ # * after_save
55
+ #
56
+ class_attribute :persisted_flag_callback_control, instance_accessor: false, default: false
57
+
18
58
  define_model_callbacks :save
19
59
  define_model_callbacks :create
20
60
  define_model_callbacks :update
@@ -35,7 +75,11 @@ module ActiveRecordCompose
35
75
  return false if invalid?
36
76
 
37
77
  with_transaction_returning_status do
38
- run_callbacks(:save) { save_models(bang: false) }
78
+ if self.class.persisted_flag_callback_control
79
+ with_callbacks { save_models(bang: false) }
80
+ else
81
+ run_callbacks(:save) { save_models(bang: false) }
82
+ end
39
83
  rescue ActiveRecord::RecordInvalid
40
84
  false
41
85
  end
@@ -50,7 +94,11 @@ module ActiveRecordCompose
50
94
  valid? || raise_validation_error
51
95
 
52
96
  with_transaction_returning_status do
53
- run_callbacks(:save) { save_models(bang: true) }
97
+ if self.class.persisted_flag_callback_control
98
+ with_callbacks { save_models(bang: true) }
99
+ else
100
+ run_callbacks(:save) { save_models(bang: true) }
101
+ end
54
102
  end || raise_on_save_error
55
103
  end
56
104
 
@@ -80,11 +128,15 @@ module ActiveRecordCompose
80
128
  # # after_save called!
81
129
  #
82
130
  def create(attributes = {})
131
+ if self.class.persisted_flag_callback_control
132
+ raise '`#create` cannot be called. The context for creation or update is determined by the `#persisted` flag.'
133
+ end
134
+
83
135
  assign_attributes(attributes)
84
136
  return false if invalid?
85
137
 
86
138
  with_transaction_returning_status do
87
- run_callbacks(:save) { run_callbacks(:create) { save_models(bang: false) } }
139
+ with_callbacks(context: :create) { save_models(bang: false) }
88
140
  rescue ActiveRecord::RecordInvalid
89
141
  false
90
142
  end
@@ -93,11 +145,15 @@ module ActiveRecordCompose
93
145
  # Behavior is same to `#create`, but raises an exception prematurely on failure.
94
146
  #
95
147
  def create!(attributes = {})
148
+ if self.class.persisted_flag_callback_control
149
+ raise '`#create` cannot be called. The context for creation or update is determined by the `#persisted` flag.'
150
+ end
151
+
96
152
  assign_attributes(attributes)
97
153
  valid? || raise_validation_error
98
154
 
99
155
  with_transaction_returning_status do
100
- run_callbacks(:save) { run_callbacks(:create) { save_models(bang: true) } }
156
+ with_callbacks(context: :create) { save_models(bang: true) }
101
157
  end || raise_on_save_error
102
158
  end
103
159
 
@@ -131,7 +187,11 @@ module ActiveRecordCompose
131
187
  return false if invalid?
132
188
 
133
189
  with_transaction_returning_status do
134
- run_callbacks(:save) { run_callbacks(:update) { save_models(bang: false) } }
190
+ if self.class.persisted_flag_callback_control
191
+ with_callbacks { save_models(bang: false) }
192
+ else
193
+ with_callbacks(context: :update) { save_models(bang: false) }
194
+ end
135
195
  rescue ActiveRecord::RecordInvalid
136
196
  false
137
197
  end
@@ -144,22 +204,29 @@ module ActiveRecordCompose
144
204
  valid? || raise_validation_error
145
205
 
146
206
  with_transaction_returning_status do
147
- run_callbacks(:save) { run_callbacks(:update) { save_models(bang: true) } }
207
+ if self.class.persisted_flag_callback_control
208
+ with_callbacks { save_models(bang: true) }
209
+ else
210
+ with_callbacks(context: :update) { save_models(bang: true) }
211
+ end
148
212
  end || raise_on_save_error
149
213
  end
150
214
 
151
215
  private
152
216
 
153
- def models = @__models ||= ActiveRecordCompose::InnerModelCollection.new(self)
217
+ def models = @__models ||= ActiveRecordCompose::ComposedCollection.new(self)
154
218
 
155
219
  def validate_models
156
- wms = models.__wrapped_models
157
- wms.select { _1.invalid? }.each { errors.merge!(_1) }
220
+ models.__wrapped_models.select { _1.invalid? }.each { errors.merge!(_1) }
221
+ end
222
+
223
+ def with_callbacks(context: nil, &block)
224
+ context ||= persisted? ? :update : :create
225
+ run_callbacks(:save) { run_callbacks(context, &block) }
158
226
  end
159
227
 
160
228
  def save_models(bang:)
161
- wms = models.__wrapped_models
162
- wms.all? { bang ? _1.save! : _1.save }
229
+ models.__wrapped_models.all? { bang ? _1.save! : _1.save }
163
230
  end
164
231
 
165
232
  def raise_validation_error = raise ActiveRecord::RecordInvalid, self
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordCompose
4
- VERSION = '0.6.2'
4
+ VERSION = '0.7.0'
5
5
  end
@@ -3,7 +3,7 @@
3
3
  require 'active_support/core_ext/object'
4
4
 
5
5
  module ActiveRecordCompose
6
- class InnerModel
6
+ class WrappedModel
7
7
  # @param model [Object] the model instance.
8
8
  # @param destroy [Boolean] given true, destroy model.
9
9
  # @param destroy [Proc] when proc returning true, destroy model.
@@ -17,7 +17,7 @@ module ActiveRecordCompose
17
17
  delegate :errors, to: :model
18
18
 
19
19
  # Determines whether to save or delete the target object.
20
- # Depends on the `destroy` value of the InnerModel object initialization option.
20
+ # Depends on the `destroy` value of the WrappedModel object initialization option.
21
21
  #
22
22
  # On the other hand, there are values `mark_for_destruction` and `marked_for_destruction?` in ActiveRecord.
23
23
  # However, these values are not substituted here.
@@ -60,12 +60,36 @@ module ActiveRecordCompose
60
60
  # Whether save or destroy is executed depends on the value of `#destroy_context?`.
61
61
  #
62
62
  # @return [Boolean] returns true on success, false on failure.
63
- def save = destroy_context? ? model.destroy : model.save
63
+ def save
64
+ # While errors caused by the type check are avoided,
65
+ # it is important to note that an error can still occur
66
+ # if `#destroy_context?` returns true but ar_like does not implement `#destroy`.
67
+ m = model
68
+ if destroy_context?
69
+ # @type var m: ActiveRecordCompose::_ARLikeWithDestroy
70
+ m.destroy
71
+ else
72
+ # @type var m: ActiveRecordCompose::_ARLike
73
+ m.save
74
+ end
75
+ end
64
76
 
65
77
  # Execute save or destroy. Unlike #save, an exception is raises on failure.
66
78
  # Whether save or destroy is executed depends on the value of `#destroy_context?`.
67
79
  #
68
- def save! = destroy_context? ? model.destroy! : model.save!
80
+ def save!
81
+ # While errors caused by the type check are avoided,
82
+ # it is important to note that an error can still occur
83
+ # if `#destroy_context?` returns true but ar_like does not implement `#destroy`.
84
+ m = model
85
+ if destroy_context?
86
+ # @type var m: ActiveRecordCompose::_ARLikeWithDestroy
87
+ m.destroy!
88
+ else
89
+ # @type var model: ActiveRecordCompose::_ARLike
90
+ m.save!
91
+ end
92
+ end
69
93
 
70
94
  # @return [Boolean]
71
95
  def invalid? = destroy_context? ? false : model.invalid?
@@ -94,7 +118,7 @@ module ActiveRecordCompose
94
118
 
95
119
  # @private
96
120
  module PackagePrivate
97
- refine InnerModel do
121
+ refine WrappedModel do
98
122
  # @private
99
123
  # Returns a model instance of raw, but it should
100
124
  # be noted that application developers are not expected to use this interface.
@@ -1,59 +1,33 @@
1
1
  module ActiveRecordCompose
2
- module DelegateAttribute : ActiveModel::Attributes
3
- extend ActiveSupport::Concern
4
-
5
- def attributes: -> Hash[String, untyped]
6
- def delegated_attributes: () -> Array[String]
7
-
8
- module ClassMethods : Module
9
- def delegate_attribute: (*untyped methods, to: untyped, ?allow_nil: untyped?, ?private: untyped?) -> untyped
10
- def delegated_attributes: () -> Array[String]
11
- def delegated_attributes=: (Array[String]) -> untyped
12
- end
13
- end
14
-
15
- class InnerModelCollection
2
+ class ComposedCollection
16
3
  def initialize: (Model) -> void
17
4
 
18
5
  private
19
6
  attr_reader owner: Model
20
- attr_reader models: Array[InnerModel]
21
- def wrap: (_ARLike | InnerModel, ?destroy: (bool | Symbol | destroy_context_type), ?if: (nil | Symbol | condition_type)) -> InnerModel
7
+ attr_reader models: Array[WrappedModel]
8
+ def wrap: (ar_like, ?destroy: (bool | Symbol | destroy_context_type), ?if: (nil | Symbol | condition_type)) -> WrappedModel
22
9
 
23
10
  module PackagePrivate
24
- def __wrapped_models: () -> Array[InnerModel]
11
+ def __wrapped_models: () -> Array[WrappedModel]
25
12
 
26
13
  private
27
- def models: () -> Array[InnerModel]
14
+ def models: () -> Array[WrappedModel]
28
15
  end
29
16
 
30
17
  include PackagePrivate
31
18
  end
32
19
 
33
- class InnerModel
34
- def initialize: (_ARLike, ?destroy: (bool | destroy_context_type), ?if: (nil | condition_type)) -> void
35
- def destroy_context?: -> bool
36
- def ignore?: -> bool
37
- def save: -> bool
38
- def save!: -> untyped
39
- def invalid?: -> bool
40
- def valid?: -> bool
41
- def is_a?: (untyped) -> bool
42
- def ==: (untyped) -> bool
43
-
44
- private
45
- attr_reader model: _ARLike
46
- attr_reader destroy_context_type: (bool | destroy_context_type)
47
- attr_reader if_option: (nil | condition_type)
20
+ module DelegateAttribute : ActiveModel::Attributes
21
+ extend ActiveSupport::Concern
48
22
 
49
- module PackagePrivate
50
- def __raw_model: () -> _ARLike
23
+ def attributes: -> Hash[String, untyped]
24
+ def delegated_attributes: () -> Array[String]
51
25
 
52
- private
53
- def model: () -> _ARLike
26
+ module ClassMethods : Module
27
+ def delegate_attribute: (*untyped methods, to: untyped, ?allow_nil: untyped?, ?private: untyped?) -> untyped
28
+ def delegated_attributes: () -> Array[String]
29
+ def delegated_attributes=: (Array[String]) -> untyped
54
30
  end
55
-
56
- include PackagePrivate
57
31
  end
58
32
 
59
33
  class Model
@@ -62,7 +36,7 @@ module ActiveRecordCompose
62
36
  include TransactionSupport
63
37
  extend TransactionSupport::ClassMethods
64
38
 
65
- @__models: InnerModelCollection
39
+ @__models: ComposedCollection
66
40
  end
67
41
 
68
42
  module TransactionSupport
@@ -79,4 +53,30 @@ module ActiveRecordCompose
79
53
  def ar_class: -> singleton(ActiveRecord::Base)
80
54
  end
81
55
  end
56
+
57
+ class WrappedModel
58
+ def initialize: (ar_like, ?destroy: (bool | destroy_context_type), ?if: (nil | condition_type)) -> void
59
+ def destroy_context?: -> bool
60
+ def ignore?: -> bool
61
+ def save: -> bool
62
+ def save!: -> untyped
63
+ def invalid?: -> bool
64
+ def valid?: -> bool
65
+ def is_a?: (untyped) -> bool
66
+ def ==: (untyped) -> bool
67
+
68
+ private
69
+ attr_reader model: ar_like
70
+ attr_reader destroy_context_type: (bool | destroy_context_type)
71
+ attr_reader if_option: (nil | condition_type)
72
+
73
+ module PackagePrivate
74
+ def __raw_model: () -> ar_like
75
+
76
+ private
77
+ def model: () -> ar_like
78
+ end
79
+
80
+ include PackagePrivate
81
+ end
82
82
  end
@@ -5,6 +5,15 @@ module ActiveRecordCompose
5
5
  VERSION: String
6
6
 
7
7
  interface _ARLike
8
+ def save: -> bool
9
+ def save!: -> untyped
10
+ def invalid?: -> bool
11
+ def valid?: -> bool
12
+ def errors: -> untyped
13
+ def is_a?: (untyped) -> bool
14
+ def ==: (untyped) -> bool
15
+ end
16
+ interface _ARLikeWithDestroy
8
17
  def save: -> bool
9
18
  def save!: -> untyped
10
19
  def destroy: -> bool
@@ -15,28 +24,53 @@ module ActiveRecordCompose
15
24
  def is_a?: (untyped) -> bool
16
25
  def ==: (untyped) -> bool
17
26
  end
27
+ type ar_like = (_ARLike | _ARLikeWithDestroy)
28
+
29
+ type condition[T] = Symbol | ^(T) [self: T] -> boolish
30
+ type callback[T] = Symbol | ^(T) [self: T] -> void
31
+ type around_callback[T] = Symbol | ^(T, Proc) [self: T] -> void
18
32
 
19
33
  type attribute_name = (String | Symbol)
20
- type destroy_context_type = ((^() -> boolish) | (^(_ARLike) -> boolish))
21
- type condition_type = ((^() -> boolish) | (^(_ARLike) -> boolish))
34
+ type destroy_context_type = ((^() -> boolish) | (^(ar_like) -> boolish))
35
+ type condition_type = ((^() -> boolish) | (^(ar_like) -> boolish))
22
36
 
23
- class InnerModelCollection
24
- include ::Enumerable[_ARLike]
37
+ class ComposedCollection
38
+ include ::Enumerable[ar_like]
25
39
 
26
- def each: () { (_ARLike) -> void } -> InnerModelCollection | () -> Enumerator[_ARLike, self]
27
- def <<: (_ARLike) -> self
28
- def push: (_ARLike, ?destroy: (bool | Symbol | destroy_context_type), ?if: (nil | Symbol | condition_type)) -> self
40
+ def each: () { (ar_like) -> void } -> ComposedCollection | () -> Enumerator[ar_like, self]
41
+ def <<: (ar_like) -> self
42
+ def push: (ar_like, ?destroy: (bool | Symbol | destroy_context_type), ?if: (nil | Symbol | condition_type)) -> self
29
43
  def empty?: -> bool
30
44
  def clear: -> self
31
- def delete: (_ARLike) -> InnerModelCollection?
45
+ def delete: (ar_like) -> ComposedCollection?
32
46
  end
33
47
 
34
48
  class Model
35
- extend ActiveModel::Callbacks
36
49
  include ActiveModel::Model
37
50
  include ActiveModel::Validations::Callbacks
38
- extend ActiveModel::Validations::ClassMethods
39
51
  include ActiveModel::Attributes
52
+ extend ActiveModel::Callbacks
53
+ extend ActiveModel::Validations::ClassMethods
54
+ extend ActiveModel::Validations::Callbacks::ClassMethods
55
+ extend ActiveModel::Attributes::ClassMethods
56
+
57
+ def self.before_save: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
58
+ def self.around_save: (*around_callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
59
+ def self.after_save: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
60
+
61
+ def self.before_create: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
62
+ def self.around_create: (*around_callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
63
+ def self.after_create: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
64
+
65
+ def self.before_update: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
66
+ def self.around_update: (*around_callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
67
+ def self.after_update: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
68
+
69
+ def self.after_commit: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
70
+ def self.after_rollback: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
71
+
72
+ def self.persisted_flag_callback_control: () -> boolish
73
+ def self.persisted_flag_callback_control=: (boolish) -> untyped
40
74
 
41
75
  def self.delegate_attribute: (*untyped methods, to: untyped, ?allow_nil: untyped?, ?private: untyped?) -> untyped
42
76
  def self.connection: -> ActiveRecord::ConnectionAdapters::AbstractAdapter
@@ -53,7 +87,8 @@ module ActiveRecordCompose
53
87
  def id: -> untyped
54
88
 
55
89
  private
56
- def models: -> InnerModelCollection
90
+ def models: -> ComposedCollection
91
+ def with_callbacks: (?context: (nil | :create | :update)) { () -> bool } -> bool
57
92
  def validate_models: -> void
58
93
  def save_models: (bang: bool) -> bool
59
94
  def raise_validation_error: -> bot
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_record_compose
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamajyotan
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-01-04 00:00:00.000000000 Z
10
+ date: 2025-02-12 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -23,7 +23,8 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '6.1'
26
- description: activemodel form object pattern
26
+ description: activemodel form object pattern. it embraces multiple AR models and provides
27
+ a transparent interface as if they were a single model.
27
28
  email:
28
29
  - hamajyotan@gmail.com
29
30
  executables: []
@@ -36,12 +37,12 @@ files:
36
37
  - LICENSE.txt
37
38
  - README.md
38
39
  - lib/active_record_compose.rb
40
+ - lib/active_record_compose/composed_collection.rb
39
41
  - lib/active_record_compose/delegate_attribute.rb
40
- - lib/active_record_compose/inner_model.rb
41
- - lib/active_record_compose/inner_model_collection.rb
42
42
  - lib/active_record_compose/model.rb
43
43
  - lib/active_record_compose/transaction_support.rb
44
44
  - lib/active_record_compose/version.rb
45
+ - lib/active_record_compose/wrapped_model.rb
45
46
  - sig/_internal/package_private.rbs
46
47
  - sig/active_record_compose.rbs
47
48
  homepage: https://github.com/hamajyotan/active_record_compose
@@ -51,7 +52,7 @@ metadata:
51
52
  homepage_uri: https://github.com/hamajyotan/active_record_compose
52
53
  source_code_uri: https://github.com/hamajyotan/active_record_compose
53
54
  changelog_uri: https://github.com/hamajyotan/active_record_compose/blob/main/CHANGELOG.md
54
- documentation_uri: https://www.rubydoc.info/gems/active_record_compose/0.6.2
55
+ documentation_uri: https://www.rubydoc.info/gems/active_record_compose/0.7.0
55
56
  rubygems_mfa_required: 'true'
56
57
  rdoc_options: []
57
58
  require_paths: