active_record_compose 1.2.0 → 1.3.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: 2622aa32a886a2c21fcc7836254285cb92f1ce7745674297c3e7fe46bcbe556a
4
- data.tar.gz: 747acd3e97cb78aba78b9d3f2c87ab4d9bd34b0727aaf2a1c94d9520ea9eaa08
3
+ metadata.gz: 148413ab669022c937e7f4775cff13dc018c9ba190014b130c10b9ca7e767a3f
4
+ data.tar.gz: 51ec332dcf2decf04dfb7f3bf8f0d38bf791d36d960d0d19c388608ef3f33a32
5
5
  SHA512:
6
- metadata.gz: e6002732e8e6fa09269ccb4a7635af4ef683d06af14e829aa062846c6dbb90c2942d5b3cc2b4d715eeef2182498e732d33b5ae004836ff703f8d8235fec12c70
7
- data.tar.gz: 18d3f9a635d3f4bf940eeb4020824fa7fc7d91cf27b0a99aa253af53e31fdca6bebd69d402537bea29749de26eba3f5a261e10a3ef2ebf079294e7fc3dbd5127
6
+ metadata.gz: 7db6b03f1a56d9d9d1ef0427362b65966bcfca997a465c4ce843d5dad9c4237e43b443ce0620629c665ab09163bf46fd362976009c2e426ae40849c1a41d18bd
7
+ data.tar.gz: 43ecb29b976c4136650e2152f1533af7e06de40afeba68f7265e97ba6eff208165271c338b4348d44127d850ebe75de2908eea60a18e986ef47e181d95d6e784
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.3.0] - 2026-08-09
4
+
5
+ * Support secondary and multiple database
6
+ (https://github.com/hamajyotan/active_record_compose/pull/74)
7
+ - When models connecting to different databases are used together, the system behaves in a manner simulating a two-phase commit.
8
+ - Due to the nature of the ActiveRecord API supported by Rails, a true two-phase commit is not implemented; instead, transactions are nested when multiple database connections are involved.
9
+ - Note that operations remain unchanged when all models belong to a single primary database.
10
+
11
+ ## [1.2.1] - 2026-03-21
12
+
13
+ * Improved clarity of error when accessing uninitialized attributes.
14
+ (https://github.com/hamajyotan/active_record_compose/pull/71)
15
+ * doc: Minor document adjustments, etc.
16
+
3
17
  ## [1.2.0] - 2026-01-05
4
18
 
5
19
  * Avoid issuing multiple saves on the same object.
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # ActiveRecordCompose
2
2
 
3
3
  ActiveRecordCompose lets you build form objects that combine multiple ActiveRecord models into a single, unified interface.
4
- More than just a simple form object, it is designed as a **business-oriented composed model** that encapsulates complex operations-such as user registration spanning multiple tables-making them easier to write, validate, and maintain.
4
+ More than a simple form object, ActiveRecordCompose 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.
5
5
 
6
6
  [![Gem Version](https://badge.fury.io/rb/active_record_compose.svg)](https://badge.fury.io/rb/active_record_compose)
7
7
  ![CI](https://github.com/hamajyotan/active_record_compose/workflows/CI/badge.svg)
@@ -29,15 +29,14 @@ More than just a simple form object, it is designed as a **business-oriented com
29
29
 
30
30
  ## Motivation
31
31
 
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
+ In Rails, `ActiveRecord::Base` is responsible for persisting data to the database and modeling application behavior through validations and callbacks.
34
33
 
35
34
  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
35
  This mixes unrelated concerns into one model, leading to unnecessary complexity.
37
36
 
38
- `ActiveModel::Model` helps here it provides the familiar API (`attribute`, `errors`, validations, callbacks) without persistence, so you can isolate logic per use case.
37
+ `ActiveModel::Model` helps address this by providing a familiar API (`attribute`, `errors`, validations, callbacks) without persistence, allowing you to isolate logic per use case.
39
38
 
40
- **ActiveRecordCompose** builds on `ActiveModel::Model` and is a powerful **business object** that acts as a first-class model within Rails.
39
+ **ActiveRecordCompose** builds on `ActiveModel::Model` and provides a powerful **business object** that acts as a first-class model within Rails.
41
40
  - Transparently accesses attributes across multiple models
42
41
  - Saves all associated models atomically in a transaction
43
42
  - Collects and exposes error information consistently
@@ -60,6 +59,10 @@ $ bundle
60
59
 
61
60
  ## Quick Start
62
61
 
62
+ For a complete runnable application, see the Sample Application section below.
63
+
64
+ The examples in this section focus on ActiveRecordCompose itself and intentionally omit surrounding application code such as migrations, routes, views, and jobs.
65
+
63
66
  ### Basic Example
64
67
 
65
68
  Suppose you have two models:
@@ -106,7 +109,7 @@ class UserRegistration < ActiveRecordCompose::Model
106
109
  end
107
110
  ```
108
111
 
109
- Usage:
112
+ Example usage:
110
113
 
111
114
  ```ruby
112
115
  # === Standalone script ===
@@ -174,7 +177,7 @@ registration.attributes
174
177
 
175
178
  ### Unified Error Handling
176
179
 
177
- Validation errors from inner models are collected into the composed model:
180
+ Validation errors from the inner models are collected into the composed object:
178
181
 
179
182
  ```ruby
180
183
  user_registration = UserRegistration.new(
@@ -274,7 +277,7 @@ model.save
274
277
 
275
278
  ### Notes on adding models dynamically
276
279
 
277
- Avoid adding `models` to the models array **after validation has already run**
280
+ Avoid adding models to the `models` array **after validation has already run**
278
281
  (for example, inside `after_validation` or `before_save` callbacks).
279
282
 
280
283
  ```ruby
@@ -323,5 +326,5 @@ The gem is available as open source under the terms of the [MIT License](https:/
323
326
 
324
327
  ## Code of Conduct
325
328
 
326
- 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).
329
+ Everyone interacting in the ActiveRecordCompose 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).
327
330
 
@@ -3,6 +3,7 @@
3
3
  require_relative "attributes/attribute_predicate"
4
4
  require_relative "attributes/delegation"
5
5
  require_relative "attributes/querying"
6
+ require_relative "exceptions"
6
7
 
7
8
  module ActiveRecordCompose
8
9
  # Provides attribute-related functionality for use within ActiveRecordCompose::Model.
@@ -127,7 +128,11 @@ module ActiveRecordCompose
127
128
  #
128
129
  # @see #attributes
129
130
  # @return [Array<String>] array of attribute name.
130
- def attribute_names = super + delegated_attributes.to_a.map { _1.attribute_name }
131
+ def attribute_names
132
+ _require_attributes_initialized do
133
+ super + delegated_attributes.to_a.map { _1.attribute_name }
134
+ end
135
+ end
131
136
 
132
137
  # Returns a hash with the attribute name as key and the attribute value as value.
133
138
  # Attributes declared with {.delegate_attribute} are also merged.
@@ -155,7 +160,24 @@ module ActiveRecordCompose
155
160
  #
156
161
  # @return [Hash<String, Object>] hash with the attribute name as key and the attribute value as value.
157
162
  def attributes
158
- super.merge(*delegated_attributes.to_a.map { _1.attribute_hash(self) })
163
+ _require_attributes_initialized do
164
+ super.merge(*delegated_attributes.to_a.map { _1.attribute_hash(self) })
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ def _write_attribute(...) = _require_attributes_initialized { super } # steep:ignore
171
+
172
+ def attribute(...) = _require_attributes_initialized { super }
173
+
174
+ def _require_attributes_initialized
175
+ unless @attributes
176
+ raise ActiveRecordCompose::UninitializedAttribute,
177
+ "No attributes have been set. Is proper initialization performed, such as calling `super` in `initialize`?"
178
+ end
179
+
180
+ yield
159
181
  end
160
182
  end
161
183
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
3
4
  require_relative "wrapped_model"
4
5
 
5
6
  module ActiveRecordCompose
@@ -117,7 +118,7 @@ module ActiveRecordCompose
117
118
  def symbol_proc_map
118
119
  @symbol_proc_map ||=
119
120
  Hash.new do |h, k|
120
- h[k] = -> { owner.__send__(k) }
121
+ h[k] = -> { owner.__send__(k) } # steep:ignore
121
122
  end
122
123
  end
123
124
 
@@ -26,4 +26,19 @@ module ActiveRecordCompose
26
26
  # outer.save #=> raises ActiveRecordCompose::CircularReferenceDetected
27
27
  #
28
28
  class CircularReferenceDetected < StandardError; end
29
+
30
+ # Occurs when accessing Attributes without initializing it.
31
+ #
32
+ # @example
33
+ # class Model < ActiveRecordCompose::Model
34
+ # def initialize
35
+ # # Intentionally not calling super...
36
+ # end
37
+ #
38
+ # attribute :foo
39
+ # end
40
+ # model = Model.new
41
+ # model.foo = 1 #=> raises ActiveRecordCompose::UninitializedAttribute
42
+ #
43
+ class UninitializedAttribute < StandardError; end
29
44
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordCompose
4
+ module TransactionSupport
5
+ # @private
6
+ # steep:ignore:start
7
+
8
+ # In older versions, the ActiveRecord::Transaction object is not passed to the block argument of #transaction.
9
+ # So instead, we define a transaction object that can be evaluated equivalently.
10
+ # This follow-up will no longer be necessary when support for Rails 7.1 is dropped.
11
+ #
12
+ class ActiveTransaction
13
+ def initialize(transaction)
14
+ @real_transaction = transaction
15
+ end
16
+
17
+ attr_reader :real_transaction
18
+
19
+ def open? = !closed?
20
+
21
+ def closed? = real_transaction&.state&.completed?
22
+
23
+ def ==(other)
24
+ return true if equal?(other)
25
+ return false unless self.class == other.class
26
+
27
+ real_transaction == other.real_transaction
28
+ end
29
+
30
+ def eql?(other)
31
+ return true if equal?(other)
32
+ return false unless self.class == other.class
33
+
34
+ real_transaction.eql?(other.real_transaction)
35
+ end
36
+
37
+ def hash = real_transaction.hash
38
+ end
39
+
40
+ # steep:ignore:end
41
+ end
42
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module ActiveRecordCompose
6
+ module TransactionSupport
7
+ # @private
8
+ class TransactionCoordinator
9
+ def add_transaction(active_transaction)
10
+ transactions << active_transaction
11
+ end
12
+
13
+ # The before_commit callback is controlled so that
14
+ # it runs just before the outermost transaction commits,
15
+ # but not just before any inner transactions commit.
16
+ #
17
+ def on_before_comitted
18
+ return if @_before_committed_called
19
+
20
+ yield
21
+ @_before_committed_called = true
22
+ end
23
+
24
+ # It processes after_commit/after_rollback only immediately
25
+ # after all transactions are closed.
26
+ #
27
+ def on_after_transaction
28
+ return unless all_finished?
29
+
30
+ with_transaction_cleanup { yield }
31
+ end
32
+
33
+ private
34
+
35
+ def with_transaction_cleanup
36
+ yield
37
+ ensure
38
+ @_before_committed_called = false
39
+ transactions.reject! { _1.closed? }
40
+ end
41
+
42
+ def all_finished? = transactions.all? { _1.closed? }
43
+
44
+ def transactions = @transactions ||= Set.new
45
+ end
46
+ end
47
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/module"
4
+ require_relative "transaction_support/active_transaction"
5
+ require_relative "transaction_support/transaction_coordinator"
4
6
 
5
7
  module ActiveRecordCompose
6
8
  module TransactionSupport
@@ -86,17 +88,17 @@ module ActiveRecordCompose
86
88
 
87
89
  # @private
88
90
  def before_committed!
89
- _run_before_commit_callbacks
91
+ transaction_coordinator.on_before_comitted { _run_before_commit_callbacks }
90
92
  end
91
93
 
92
94
  # @private
93
95
  def committed!(should_run_callbacks: true)
94
- _run_commit_callbacks if should_run_callbacks
96
+ transaction_coordinator.on_after_transaction { _run_commit_callbacks if should_run_callbacks }
95
97
  end
96
98
 
97
99
  # @private
98
100
  def rolledback!(force_restore_state: false, should_run_callbacks: true)
99
- _run_rollback_callbacks if should_run_callbacks
101
+ transaction_coordinator.on_after_transaction { _run_rollback_callbacks if should_run_callbacks }
100
102
  end
101
103
  end
102
104
 
@@ -104,23 +106,49 @@ module ActiveRecordCompose
104
106
 
105
107
  def save!(**options) = with_transaction_returning_status { super }
106
108
 
107
- private
108
-
109
109
  # @private
110
- def with_transaction_returning_status
111
- connection_pool.with_connection do |connection|
112
- with_pool_transaction_isolation_level(connection) do
113
- ensure_finalize = !connection.transaction_open?
110
+ def connection_pools
111
+ pools =
112
+ models.compact.flat_map do |model|
113
+ if model.is_a?(ActiveRecordCompose::TransactionSupport)
114
+ model.connection_pools
115
+ elsif model.is_a?(ActiveRecord::Base)
116
+ connection_pool(ar_class: model.class)
117
+ else
118
+ [] # : Array[ActiveRecord::ConnectionAdapters::ConnectionPool]
119
+ end
120
+ end
121
+ pools << connection_pool if pools.blank?
122
+ pools.uniq
123
+ end
114
124
 
115
- connection.transaction do
116
- connection.add_transaction_record(self, ensure_finalize || has_transactional_callbacks?) # steep:ignore
125
+ private
117
126
 
118
- yield.tap { raise ActiveRecord::Rollback unless _1 }
119
- end || false
127
+ # @private
128
+ def with_transaction_returning_status(&block)
129
+ procs = connection_pools.inject(block) do |inner_proc, pool|
130
+ -> do
131
+ pool.with_connection do |connection|
132
+ with_pool_transaction_isolation_level(connection) do
133
+ ensure_finalize = !connection.transaction_open?
134
+
135
+ connection.transaction do |tran|
136
+ tran ||= ActiveTransaction.new(connection.current_transaction) # steep:ignore
137
+ transaction_coordinator.add_transaction(tran)
138
+ connection.add_transaction_record(self, ensure_finalize || has_transactional_callbacks?)
139
+
140
+ inner_proc.call.tap { raise ActiveRecord::Rollback unless _1 }
141
+ end
142
+ end
143
+ end
120
144
  end
121
145
  end
146
+ procs.call || false
122
147
  end
123
148
 
149
+ # @private
150
+ def transaction_coordinator = @transaction_coordinator ||= TransactionCoordinator.new
151
+
124
152
  # @private
125
153
  def default_ar_class = ActiveRecord::Base
126
154
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "composed_collection"
4
+ require_relative "exceptions"
4
5
 
5
6
  module ActiveRecordCompose
6
7
  using ComposedCollection::PackagePrivate
@@ -44,7 +45,7 @@ module ActiveRecordCompose
44
45
  # Returns the `ActiveModel::Errors` object that holds all information about attribute error messages.
45
46
  #
46
47
  # The `ActiveModel::Base` implementation itself,
47
- # but also aggregates error information for objects stored in {#models} when validation is performed.
48
+ # but also aggregates error information for objects stored in {ActiveRecordCompose::Model#models} when validation is performed.
48
49
  #
49
50
  # class Account < ActiveRecord::Base
50
51
  # validates :name, :email, presence: true
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordCompose
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
@@ -11,6 +11,9 @@ module ActiveRecordCompose
11
11
 
12
12
  @attributes: untyped
13
13
 
14
+ private
15
+ def _require_attributes_initialized: [T] () { () -> T } -> T
16
+
14
17
  class AttributePredicate
15
18
  def initialize: (untyped value) -> void
16
19
  def call: -> bool
@@ -115,6 +118,8 @@ module ActiveRecordCompose
115
118
  include ActiveSupport::Callbacks
116
119
  extend ActiveSupport::Callbacks::ClassMethods
117
120
 
121
+ @transaction_coordinator: ActiveRecordCompose::TransactionSupport::TransactionCoordinator
122
+
118
123
  def self.before_commit: (*untyped) -> untyped
119
124
  def self.after_commit: (*untyped) -> untyped
120
125
  def self.after_rollback: (*untyped) -> untyped
@@ -124,11 +129,28 @@ module ActiveRecordCompose
124
129
  def _run_before_commit_callbacks: () -> untyped
125
130
  def _run_commit_callbacks: () -> untyped
126
131
  def _run_rollback_callbacks: () -> untyped
132
+ def connection_pools: () -> Array[ActiveRecord::ConnectionAdapters::ConnectionPool]
127
133
 
128
134
  private
135
+ def models: -> ComposedCollection
129
136
  def default_ar_class: -> singleton(ActiveRecord::Base)
130
137
  def connection_pool: (?ar_class: singleton(ActiveRecord::Base)) -> ActiveRecord::ConnectionAdapters::ConnectionPool
131
138
  def with_pool_transaction_isolation_level: [T] (ActiveRecord::ConnectionAdapters::AbstractAdapter) { () -> T } -> T
139
+ def transaction_coordinator: () -> ActiveRecordCompose::TransactionSupport::TransactionCoordinator
140
+
141
+ class TransactionCoordinator
142
+ @_before_committed_called: bool
143
+ @transactions: Set[ActiveRecord::ConnectionAdapters::Transaction]
144
+
145
+ def add_transaction: (ActiveRecord::ConnectionAdapters::Transaction) -> void
146
+ def on_after_transaction: () { () -> void } -> void
147
+ def on_before_comitted: () { () -> void } -> void
148
+
149
+ private
150
+ def all_finished?: -> bool
151
+ def transactions: () -> Set[ActiveRecord::ConnectionAdapters::Transaction]
152
+ def with_transaction_cleanup: () { () -> void } -> void
153
+ end
132
154
  end
133
155
 
134
156
  module Persistence
@@ -98,4 +98,7 @@ module ActiveRecordCompose
98
98
  class Railtie < Rails::Railtie
99
99
  extend Rails::Initializable::ClassMethods
100
100
  end
101
+
102
+ class UninitializedAttribute < StandardError
103
+ end
101
104
  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: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamajyotan
@@ -56,6 +56,8 @@ files:
56
56
  - lib/active_record_compose/persistence.rb
57
57
  - lib/active_record_compose/railtie.rb
58
58
  - lib/active_record_compose/transaction_support.rb
59
+ - lib/active_record_compose/transaction_support/active_transaction.rb
60
+ - lib/active_record_compose/transaction_support/transaction_coordinator.rb
59
61
  - lib/active_record_compose/validations.rb
60
62
  - lib/active_record_compose/version.rb
61
63
  - lib/active_record_compose/wrapped_model.rb
@@ -84,7 +86,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
84
86
  - !ruby/object:Gem::Version
85
87
  version: '0'
86
88
  requirements: []
87
- rubygems_version: 4.0.3
89
+ rubygems_version: 4.0.16
88
90
  specification_version: 4
89
91
  summary: activemodel form object pattern
90
92
  test_files: []