active_record_compose 0.11.3 → 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: 154ee35310ede836e5d89ecd703efadbbc2b7fba0a6229797e9b38b5288eba47
4
- data.tar.gz: 42c565c5093680ae089bac356541561dc83ff73759941f268af22365338126db
3
+ metadata.gz: 9cdfb23b1af43681e6c4af3e9fed615bc3dc64873461bdee6f49c92f706f33e9
4
+ data.tar.gz: 4530c0987f038605cb6dc3da229df21e8cf16af29d6ca8cf3078b1dc933cc838
5
5
  SHA512:
6
- metadata.gz: a5a9f0a46534ea0dbb3423275a68b521543c67074d8fcd7c637d46f86fcc42f93ac6792b2d80f83b6a0aae367f6dd461e6fb4d0e06730f40da3aefd0751393e9
7
- data.tar.gz: c5bb3aaae6e84b23bc264ce5a1ad7b369f248c1fb1f1494b2753a1a82681ecc3df8c5f10a6b68854cda319c65da59e69435dd1c4381f44ff585d81a2400b5bac
6
+ metadata.gz: 10c37b41ff66acd993e2f5c412ed5f48213f29344a94e0ab27d40766e11f60197808d4d1b9e97118f729b4be1d272a4bdae100c8e06489c40008ac3c92f6fe61
7
+ data.tar.gz: 67c4086fce4661e89188161fd6d23610bca3dcb9ad8a6e781162909d07fbf750add3ad804d8e58bd92bf4fc1305bcebd2452a4a9d924af70e56e6a0217ecf70f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.0.0] - 2025-09-23
4
+
5
+ - drop support rails 7.0.x
6
+
7
+ ## [0.12.0] - 2025-08-21
8
+
9
+ - Omits default arguments for `#update` and `#update!`. It's to align I/F with ActiveRecord.
10
+ (https://github.com/hamajyotan/active_record_compose/pull/25)
11
+ - `#update(attributes = {})` to `#update(attributes)`
12
+ - `#update!(attributes = {})` to `#update!(attributes)`
13
+ - Omitted Specify instance variables in the `:to` option of `delegate_attribute`.
14
+ (https://github.com/hamajyotan/active_record_compose/pull/29)
15
+ - Omitted `#destroy` and `#touch` from `ActiveRecordCompose::Model`.
16
+ These were unintentionally provided by the `ActiveRecord::Transactions` module. The but in fact did not work correctly.
17
+ (https://github.com/hamajyotan/active_record_compose/pull/27)
18
+
3
19
  ## [0.11.3] - 2025-07-13
4
20
 
5
21
  - refactor: Aggregation attribute module.
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
 
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordCompose
4
+ module Attributes
5
+ # @private
6
+ class AttributePredicate
7
+ def initialize(value)
8
+ @value = value
9
+ end
10
+
11
+ def call
12
+ case value
13
+ when true then true
14
+ when false, nil then false
15
+ else
16
+ if value.respond_to?(:zero?)
17
+ !value.zero?
18
+ else
19
+ value.present?
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :value
27
+ end
28
+ end
29
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "attribute_predicate"
4
+
3
5
  module ActiveRecordCompose
4
6
  module Attributes
5
7
  # @private
@@ -17,7 +19,11 @@ module ActiveRecordCompose
17
19
 
18
20
  def define_delegated_attribute(klass)
19
21
  klass.delegate(reader, writer, to:, allow_nil:)
20
- klass.define_attribute_methods(attribute)
22
+ klass.module_eval <<~RUBY, __FILE__, __LINE__ + 1
23
+ def #{reader}?
24
+ ActiveRecordCompose::Attributes::AttributePredicate.new(#{reader}).call
25
+ end
26
+ RUBY
21
27
  end
22
28
 
23
29
  # @return [String] The attribute name as string
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "attribute_predicate"
4
+
3
5
  module ActiveRecordCompose
4
6
  module Attributes
5
7
  # @private
@@ -50,19 +52,10 @@ module ActiveRecordCompose
50
52
 
51
53
  private
52
54
 
53
- def attribute?(attr_name)
54
- value = public_send(attr_name)
55
+ def attribute?(attr_name) = query?(public_send(attr_name))
55
56
 
56
- case value
57
- when true then true
58
- when false, nil then false
59
- else
60
- if value.respond_to?(:zero?)
61
- !value.zero?
62
- else
63
- value.present?
64
- end
65
- end
57
+ def query?(value)
58
+ ActiveRecordCompose::Attributes::AttributePredicate.new(value).call
66
59
  end
67
60
  end
68
61
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "attributes/attribute_predicate"
3
4
  require_relative "attributes/delegation"
4
5
  require_relative "attributes/querying"
5
6
 
@@ -62,9 +63,6 @@ module ActiveRecordCompose
62
63
  end
63
64
 
64
65
  module ClassMethods
65
- ALLOW_NIL_DEFAULT = Object.new.freeze # steep:ignore
66
- private_constant :ALLOW_NIL_DEFAULT
67
-
68
66
  # Defines the reader and writer for the specified attribute.
69
67
  #
70
68
  # @example
@@ -97,34 +95,10 @@ module ActiveRecordCompose
97
95
  # registration.attributes
98
96
  # # => { "original_attribute" => "qux", "name" => "bar" }
99
97
  #
100
- def delegate_attribute(*attributes, to:, allow_nil: ALLOW_NIL_DEFAULT) # steep:ignore
101
- # steep:ignore:start
98
+ def delegate_attribute(*attributes, to:, allow_nil: false)
102
99
  if to.start_with?("@")
103
- suggested_reader_name = to.to_s.sub(/^@+/, "")
104
- suggested_method =
105
- if to.start_with?("@@")
106
- "def #{suggested_reader_name} = #{to}"
107
- else
108
- "attr_reader :#{suggested_reader_name}"
109
- end
110
-
111
- message = <<~MSG
112
- Direct use of instance or class variables in `to:` will be removed in the next minor version.
113
- Please define a reader method (private is fine) and refer to it by name instead.
114
-
115
- For example,
116
- delegate_attribute #{attributes.map { ":#{_1}" }.join(", ")}, to: :#{to}#{", allow_nil: #{allow_nil}" if allow_nil != ALLOW_NIL_DEFAULT}
117
-
118
- Instead of the above, use the following
119
- delegate_attribute #{attributes.map { ":#{_1}" }.join(", ")}, to: :#{suggested_reader_name}#{", allow_nil: #{allow_nil}" if allow_nil != ALLOW_NIL_DEFAULT}
120
- private
121
- #{suggested_method}
122
-
123
- MSG
124
- (ActiveRecord.respond_to?(:deprecator) ? ActiveRecord.deprecator : ActiveSupport::Deprecation).warn(message)
100
+ raise ArgumentError, "Instance variables cannot be specified in delegate to. (#{to})"
125
101
  end
126
- allow_nil = false if allow_nil == ALLOW_NIL_DEFAULT
127
- # steep:ignore:end
128
102
 
129
103
  delegations = attributes.map { Delegation.new(attribute: _1, to:, allow_nil:) }
130
104
  delegations.each { _1.define_delegated_attribute(self) }
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "validations"
4
-
5
3
  module ActiveRecordCompose
6
4
  # @private
7
5
  #
@@ -23,8 +21,6 @@ module ActiveRecordCompose
23
21
  include ActiveModel::Validations::Callbacks
24
22
 
25
23
  included do
26
- include ActiveRecordCompose::Validations
27
-
28
24
  define_model_callbacks :save
29
25
  define_model_callbacks :create
30
26
  define_model_callbacks :update
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "attributes"
4
- require_relative "callbacks"
5
4
  require_relative "composed_collection"
6
5
  require_relative "persistence"
6
+ require_relative "transaction_support"
7
+ require_relative "validations"
7
8
 
8
9
  module ActiveRecordCompose
9
10
  # This is the core class of {ActiveRecordCompose}.
@@ -83,7 +84,8 @@ module ActiveRecordCompose
83
84
 
84
85
  include ActiveRecordCompose::Attributes
85
86
  include ActiveRecordCompose::Persistence
86
- include ActiveRecordCompose::Callbacks
87
+ include ActiveRecordCompose::Validations
88
+ include ActiveRecordCompose::TransactionSupport
87
89
 
88
90
  begin
89
91
  # @group Model Core
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "callbacks"
3
4
  require_relative "composed_collection"
4
- require_relative "transaction_support"
5
5
 
6
6
  module ActiveRecordCompose
7
7
  using ComposedCollection::PackagePrivate
@@ -9,7 +9,7 @@ module ActiveRecordCompose
9
9
  # @private
10
10
  module Persistence
11
11
  extend ActiveSupport::Concern
12
- include ActiveRecordCompose::TransactionSupport
12
+ include ActiveRecordCompose::Callbacks
13
13
 
14
14
  # Save the models that exist in models.
15
15
  # Returns false if any of the targets fail, true if all succeed.
@@ -24,11 +24,9 @@ module ActiveRecordCompose
24
24
  #
25
25
  # @return [Boolean] returns true on success, false on failure.
26
26
  def save(**options)
27
- with_transaction_returning_status do
28
- with_callbacks { save_models(**options, bang: false) }
29
- rescue ActiveRecord::RecordInvalid
30
- false
31
- end
27
+ with_callbacks { save_models(**options, bang: false) }
28
+ rescue ActiveRecord::RecordInvalid
29
+ false
32
30
  end
33
31
 
34
32
  # Save the models that exist in models.
@@ -43,28 +41,22 @@ module ActiveRecordCompose
43
41
  # If the contexts differ, we recommend separating them into different model definitions.
44
42
  #
45
43
  def save!(**options)
46
- with_transaction_returning_status do
47
- with_callbacks { save_models(**options, bang: true) }
48
- end || raise_on_save_error
44
+ with_callbacks { save_models(**options, bang: true) } || raise_on_save_error
49
45
  end
50
46
 
51
47
  # Assign attributes and save.
52
48
  #
53
49
  # @return [Boolean] returns true on success, false on failure.
54
- def update(attributes = {})
55
- with_transaction_returning_status do
56
- assign_attributes(attributes)
57
- save
58
- end
50
+ def update(attributes)
51
+ assign_attributes(attributes)
52
+ save
59
53
  end
60
54
 
61
55
  # Behavior is same to `#update`, but raises an exception prematurely on failure.
62
56
  #
63
- def update!(attributes = {})
64
- with_transaction_returning_status do
65
- assign_attributes(attributes)
66
- save!
67
- end
57
+ def update!(attributes)
58
+ assign_attributes(attributes)
59
+ save!
68
60
  end
69
61
 
70
62
  private
@@ -6,18 +6,19 @@ module ActiveRecordCompose
6
6
  extend ActiveSupport::Concern
7
7
  include ActiveRecord::Transactions
8
8
 
9
- module ClassMethods
10
- def lease_connection
11
- if ar_class.respond_to?(:lease_connection)
12
- ar_class.lease_connection # steep:ignore
13
- else
14
- ar_class.connection
15
- end
16
- end
9
+ included do
10
+ # ActiveRecord::Transactions is defined so that methods such as save,
11
+ # destroy and touch are wrapped with_transaction_returning_status.
12
+ # However, ActiveRecordCompose::Model does not support destroy and touch, and
13
+ # we want to keep these operations as undefined behavior, so we remove the definition here.
14
+ undef_method :destroy, :touch
15
+ end
17
16
 
18
- def connection = ar_class.connection
17
+ module ClassMethods
18
+ delegate :with_connection, :lease_connection, to: :ar_class
19
19
 
20
- def with_connection(&) = ar_class.with_connection(&) # steep:ignore
20
+ # In ActiveRecord, it is soft deprecated.
21
+ delegate :connection, to: :ar_class
21
22
 
22
23
  def composite_primary_key? = false # steep:ignore
23
24
 
@@ -8,6 +8,7 @@ module ActiveRecordCompose
8
8
  # @private
9
9
  module Validations
10
10
  extend ActiveSupport::Concern
11
+ include ActiveModel::Validations::Callbacks
11
12
 
12
13
  included do
13
14
  validate :validate_models
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordCompose
4
- VERSION = "0.11.3"
4
+ VERSION = "1.0.0"
5
5
  end
@@ -15,6 +15,16 @@ module ActiveRecordCompose
15
15
  def delegated_attributes=: (Array[Delegation]) -> untyped
16
16
  end
17
17
 
18
+ class AttributePredicate
19
+ def initialize: (untyped value) -> void
20
+ def call: -> bool
21
+
22
+ @value: untyped
23
+
24
+ private
25
+ attr_reader value: untyped
26
+ end
27
+
18
28
  class Delegation
19
29
  def initialize: (attribute: String, to: Symbol, ?allow_nil: bool) -> void
20
30
  def attribute: () -> Symbol
@@ -40,6 +50,7 @@ module ActiveRecordCompose
40
50
 
41
51
  private
42
52
  def attribute?: (attribute_name) -> untyped
53
+ def query?: (untyped value) -> bool
43
54
  end
44
55
  end
45
56
 
@@ -87,6 +98,7 @@ module ActiveRecordCompose
87
98
  end
88
99
 
89
100
  module TransactionSupport
101
+ extend ActiveSupport::Concern
90
102
  include ActiveRecord::Transactions
91
103
 
92
104
  def id: -> untyped
@@ -108,8 +120,8 @@ module ActiveRecordCompose
108
120
 
109
121
  def save: (**untyped options) -> bool
110
122
  def save!: (**untyped options) -> untyped
111
- def update: (?Hash[attribute_name, untyped]) -> bool
112
- def update!: (?Hash[attribute_name, untyped]) -> untyped
123
+ def update: (Hash[attribute_name, untyped]) -> bool
124
+ def update!: (Hash[attribute_name, untyped]) -> untyped
113
125
 
114
126
  private
115
127
  def models: -> ComposedCollection
@@ -79,14 +79,10 @@ module ActiveRecordCompose
79
79
  def initialize: (?Hash[attribute_name, untyped]) -> void
80
80
  def save: (**untyped options) -> bool
81
81
  def save!: (**untyped options) -> untyped
82
- def update: (?Hash[attribute_name, untyped]) -> bool
83
- def update!: (?Hash[attribute_name, untyped]) -> untyped
84
- def id: -> untyped
82
+ def update: (Hash[attribute_name, untyped]) -> bool
83
+ def update!: (Hash[attribute_name, untyped]) -> untyped
85
84
 
86
85
  private
87
86
  def models: -> ComposedCollection
88
- def save_models: (bang: bool, **untyped options) -> bool
89
- def raise_on_save_error: -> bot
90
- def raise_on_save_error_message: -> String
91
87
  end
92
88
  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.11.3
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'
@@ -45,6 +45,7 @@ files:
45
45
  - README.md
46
46
  - lib/active_record_compose.rb
47
47
  - lib/active_record_compose/attributes.rb
48
+ - lib/active_record_compose/attributes/attribute_predicate.rb
48
49
  - lib/active_record_compose/attributes/delegation.rb
49
50
  - lib/active_record_compose/attributes/querying.rb
50
51
  - lib/active_record_compose/callbacks.rb
@@ -80,7 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
80
81
  - !ruby/object:Gem::Version
81
82
  version: '0'
82
83
  requirements: []
83
- rubygems_version: 3.6.7
84
+ rubygems_version: 3.7.2
84
85
  specification_version: 4
85
86
  summary: activemodel form object pattern
86
87
  test_files: []