active_record_compose 1.1.1 → 1.2.1

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: 491f46737a3744c5c5d3e0319de3ee9f64ae21a9adfab39f36cedc478bfa0e7c
4
- data.tar.gz: 406f4c471e0d7c40ec7b3a0d492f8e7e6c96d93fd4b43204a128bf52c1f7ed4c
3
+ metadata.gz: 810feb87760ff992aa21ca019081e6072ec43c99387ea0ee013f767d4d2c875c
4
+ data.tar.gz: 7077c4d5b79d12c6c5f5a0297d0d2837c3d3c79a3b8e74a13c12e05a3511c078
5
5
  SHA512:
6
- metadata.gz: 9ff0e91cfb0ca22322d1b9ca47c32f09372afa206e9fa6690c1dfe5ca30d221269ccc55fe17556627234ea8a7019118ddec8a46ad24a52c47239e694dbcac323
7
- data.tar.gz: 925001a566ee81600ce91862c0c95f241dba2cd735f6ecbc3e987e83e6cd6f59d217e67afa4bed0a981f35c16645661ef7eeb00adb18df92999f9127e22116e0
6
+ metadata.gz: 283b3beaab24fb19d30e134dbe543a25c730bf0d331bdd5114ae89747d0a247b8b572d7e6f2578c06d80f2f7cb4b8c6023f2640518c5b0dc21d335401e45ab5c
7
+ data.tar.gz: 6f55ebeb0bd4379d206f55b8dc8dc618beca9d281f0d425c0cc8d96078d60ed74fd26c67b92b2bb8c26f9cb6dd4634557ef1064990c4a7ca91bb15e99d6c381e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.2.1] - 2026-03-21
4
+
5
+ * Improved clarity of error when accessing uninitialized attributes.
6
+ (https://github.com/hamajyotan/active_record_compose/pull/71)
7
+ * doc: Minor document adjustments, etc.
8
+
9
+ ## [1.2.0] - 2026-01-05
10
+
11
+ * Avoid issuing multiple saves on the same object.
12
+ (https://github.com/hamajyotan/active_record_compose/pull/56)
13
+ * The storage of `#models` has been changed from an Array to a Set.
14
+ This prevents duplicate additions of the same object and option combinations.
15
+ Also, `#models#delete` now deletes the model regardless of the options used when it was added.
16
+ (https://github.com/hamajyotan/active_record_compose/pull/57)
17
+ * Adding an `ActiveRecordCompose::Model` to `#models` now throws an error if there is a circular reference.
18
+ (https://github.com/hamajyotan/active_record_compose/pull/58)
19
+
3
20
  ## [1.1.1] - 2025-12-04
4
21
 
5
22
  * fix: the save method would return nil instead of false.
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
@@ -106,7 +105,7 @@ class UserRegistration < ActiveRecordCompose::Model
106
105
  end
107
106
  ```
108
107
 
109
- Usage:
108
+ Example usage:
110
109
 
111
110
  ```ruby
112
111
  # === Standalone script ===
@@ -174,7 +173,7 @@ registration.attributes
174
173
 
175
174
  ### Unified Error Handling
176
175
 
177
- Validation errors from inner models are collected into the composed model:
176
+ Validation errors from the inner models are collected into the composed object:
178
177
 
179
178
  ```ruby
180
179
  user_registration = UserRegistration.new(
@@ -274,7 +273,7 @@ model.save
274
273
 
275
274
  ### Notes on adding models dynamically
276
275
 
277
- Avoid adding `models` to the models array **after validation has already run**
276
+ Avoid adding models to the `models` array **after validation has already run**
278
277
  (for example, inside `after_validation` or `before_save` callbacks).
279
278
 
280
279
  ```ruby
@@ -323,5 +322,5 @@ The gem is available as open source under the terms of the [MIT License](https:/
323
322
 
324
323
  ## Code of Conduct
325
324
 
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).
325
+ 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
326
 
@@ -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
@@ -13,7 +13,7 @@ module ActiveRecordCompose
13
13
 
14
14
  def initialize(owner)
15
15
  @owner = owner
16
- @models = []
16
+ @models = Set.new
17
17
  end
18
18
 
19
19
  # Enumerates model objects.
@@ -69,13 +69,28 @@ module ActiveRecordCompose
69
69
  # Removes the specified model from the collection.
70
70
  # Returns nil if the deletion fails, self if it succeeds.
71
71
  #
72
+ # The specified model instance will be deleted regardless of the options used when it was added.
73
+ #
74
+ # @example
75
+ # model_a = Model.new
76
+ # model_b = Model.new
77
+ #
78
+ # collection.push(model_a, destroy: true)
79
+ # collection.push(model_b)
80
+ # collection.push(model_a, destroy: false)
81
+ # collection.count #=> 3
82
+ #
83
+ # collection.delete(model_a)
84
+ # collection.count #=> 1
85
+ #
72
86
  # @param model [Object] model instance
73
87
  # @return [self] Successful deletion
74
88
  # @return [nil] If deletion fails
75
89
  def delete(model)
76
- wrapped = wrap(model)
77
- return nil unless models.delete(wrapped)
90
+ matched = models.select { _1.__raw_model == model }
91
+ return nil if matched.blank?
78
92
 
93
+ matched.each { models.delete(_1) }
79
94
  self
80
95
  end
81
96
 
@@ -87,17 +102,27 @@ module ActiveRecordCompose
87
102
  # @private
88
103
  def wrap(model, destroy: false, if: nil)
89
104
  if destroy.is_a?(Symbol)
90
- method = destroy
91
- destroy = -> { owner.__send__(method) }
105
+ destroy = symbol_proc_map[destroy]
92
106
  end
107
+
93
108
  if_option = binding.local_variable_get(:if)
94
109
  if if_option.is_a?(Symbol)
95
- method = if_option
96
- if_option = -> { owner.__send__(method) }
110
+ if_option = symbol_proc_map[if_option]
97
111
  end
112
+
98
113
  ActiveRecordCompose::WrappedModel.new(model, destroy:, if: if_option)
99
114
  end
100
115
 
116
+ # @private
117
+ def symbol_proc_map
118
+ @symbol_proc_map ||=
119
+ Hash.new do |h, k|
120
+ h[k] = -> { owner.__send__(k) }
121
+ end
122
+ end
123
+
124
+ def instance_variables_to_inspect = %i[@owner @models]
125
+
101
126
  # @private
102
127
  module PackagePrivate
103
128
  refine ComposedCollection do
@@ -105,7 +130,9 @@ module ActiveRecordCompose
105
130
  #
106
131
  # @private
107
132
  # @return [Array[WrappedModel]] array of wrapped model instance.
108
- def __wrapped_models = models.reject { _1.ignore? }.select { _1.__raw_model }
133
+ def __wrapped_models
134
+ models.reject { _1.ignore? }.uniq { [ _1.__raw_model, !!_1.destroy_context? ] }.select { _1.__raw_model }
135
+ end
109
136
  end
110
137
  end
111
138
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordCompose
4
+ # Occurs when a circular reference is detected in the containing model.
5
+ #
6
+ # @example
7
+ # class Model < ActiveRecordCompose::Model
8
+ # def initialize
9
+ # super()
10
+ # models << self # Adding itself to models creates a circular reference.
11
+ # end
12
+ # end
13
+ # model = Model.new
14
+ # model.save #=> raises ActiveRecordCompose::CircularReferenceDetected
15
+ #
16
+ # @example
17
+ # class Model < ActiveRecordCompose::Model
18
+ # attribute :model
19
+ # before_validation { models << model }
20
+ # end
21
+ # inner = Model.new
22
+ # middle = Model.new(model: inner)
23
+ # outer = Model.new(model: middle)
24
+ #
25
+ # inner.model = outer # There is a circular reference in the form outer > middle > inner > outer.
26
+ # outer.save #=> raises ActiveRecordCompose::CircularReferenceDetected
27
+ #
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
44
+ end
@@ -113,7 +113,7 @@ module ActiveRecordCompose
113
113
  ensure_finalize = !connection.transaction_open?
114
114
 
115
115
  connection.transaction do
116
- connection.add_transaction_record(self, ensure_finalize || has_transactional_callbacks?) # steep:ignore
116
+ connection.add_transaction_record(self, ensure_finalize || has_transactional_callbacks?)
117
117
 
118
118
  yield.tap { raise ActiveRecord::Rollback unless _1 }
119
119
  end || false
@@ -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
@@ -76,10 +77,24 @@ module ActiveRecordCompose
76
77
  #
77
78
  # @return [ActiveModel::Errors]
78
79
 
80
+ # @private
81
+ def detect_circular_reference(targets = [])
82
+ raise CircularReferenceDetected if targets.include?(object_id)
83
+
84
+ targets += [ object_id ]
85
+ # steep:ignore:start
86
+ models.select { _1.respond_to?(:detect_circular_reference) }.each do |m|
87
+ m.detect_circular_reference(targets)
88
+ end
89
+ # steep:ignore:end
90
+ end
91
+
79
92
  private
80
93
 
81
94
  # @private
82
95
  def validate_models
96
+ detect_circular_reference
97
+
83
98
  context = override_validation_context
84
99
  models.__wrapped_models.lazy.select { _1.invalid?(context) }.each { errors.merge!(_1) }
85
100
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordCompose
4
- VERSION = "1.1.1"
4
+ VERSION = "1.2.1"
5
5
  end
@@ -104,19 +104,28 @@ module ActiveRecordCompose
104
104
  # @param [Object] other
105
105
  # @return [Boolean]
106
106
  def ==(other)
107
+ return true if equal?(other)
107
108
  return false unless self.class == other.class
108
- return false unless model == other.model
109
109
 
110
- true
110
+ equality_key == other.equality_key
111
111
  end
112
112
 
113
+ def eql?(other)
114
+ return true if equal?(other)
115
+ return false unless self.class == other.class
116
+
117
+ equality_key.eql?(other.equality_key)
118
+ end
119
+
120
+ def hash = equality_key.hash
121
+
113
122
  protected
114
123
 
115
- attr_reader :model
124
+ def equality_key = [ model, destroy_context_type, if_option ]
116
125
 
117
126
  private
118
127
 
119
- attr_reader :destroy_context_type, :if_option
128
+ attr_reader :model, :destroy_context_type, :if_option
120
129
 
121
130
  # @private
122
131
  module PackagePrivate
@@ -3,6 +3,7 @@
3
3
  require "active_record"
4
4
 
5
5
  require_relative "active_record_compose/version"
6
+ require_relative "active_record_compose/exceptions"
6
7
  require_relative "active_record_compose/model"
7
8
 
8
9
  # namespaces in gem `active_record_compose`.
@@ -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
@@ -64,16 +67,20 @@ module ActiveRecordCompose
64
67
  class ComposedCollection
65
68
  def initialize: (Model) -> void
66
69
 
70
+ @symbol_proc_map: Hash[Symbol, (destroy_context_type | condition_type)]
71
+
67
72
  private
68
73
  attr_reader owner: Model
69
- attr_reader models: Array[WrappedModel]
74
+ attr_reader models: Set[WrappedModel]
70
75
  def wrap: (ar_like, ?destroy: (bool | Symbol | destroy_context_type), ?if: (nil | Symbol | condition_type)) -> WrappedModel
76
+ def symbol_proc_map: () -> Hash[Symbol, (destroy_context_type | condition_type)]
77
+ def instance_variables_to_inspect: () -> Array[Symbol]
71
78
 
72
79
  module PackagePrivate
73
- def __wrapped_models: () -> Array[WrappedModel]
80
+ def __wrapped_models: () -> Enumerable[WrappedModel]
74
81
 
75
82
  private
76
- def models: () -> Array[WrappedModel]
83
+ def models: () -> Set[WrappedModel]
77
84
  end
78
85
 
79
86
  include PackagePrivate
@@ -143,10 +150,6 @@ module ActiveRecordCompose
143
150
  def raise_on_save_error_message: -> String
144
151
  end
145
152
 
146
- class Railtie < Rails::Railtie
147
- extend Rails::Initializable::ClassMethods
148
- end
149
-
150
153
  module Validations : Model
151
154
  extend ActiveSupport::Concern
152
155
  extend ActiveModel::Validations::ClassMethods
@@ -154,6 +157,7 @@ module ActiveRecordCompose
154
157
  def save: (**untyped options) -> bool
155
158
  def save!: (**untyped options) -> untyped
156
159
  def valid?: (?validation_context context) -> bool
160
+ def detect_circular_reference: (?Array[Integer])-> untyped
157
161
 
158
162
  @context_for_override_validation: OverrideValidationContext
159
163
 
@@ -187,6 +191,7 @@ module ActiveRecordCompose
187
191
  attr_reader model: ar_like
188
192
  attr_reader destroy_context_type: (bool | destroy_context_type)
189
193
  attr_reader if_option: (nil | condition_type)
194
+ def equality_key: () -> [ar_like, (bool | destroy_context_type), (nil | condition_type)]
190
195
 
191
196
  module PackagePrivate
192
197
  def __raw_model: () -> ar_like
@@ -47,6 +47,9 @@ module ActiveRecordCompose
47
47
  def delete: (ar_like) -> ComposedCollection?
48
48
  end
49
49
 
50
+ class CircularReferenceDetected < StandardError
51
+ end
52
+
50
53
  class Model
51
54
  include ActiveModel::Model
52
55
  include ActiveModel::Validations::Callbacks
@@ -68,6 +71,7 @@ module ActiveRecordCompose
68
71
  def self.around_update: (*around_callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
69
72
  def self.after_update: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
70
73
 
74
+ def self.before_commit: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
71
75
  def self.after_commit: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
72
76
  def self.after_rollback: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
73
77
 
@@ -90,4 +94,11 @@ module ActiveRecordCompose
90
94
  private
91
95
  def models: -> ComposedCollection
92
96
  end
97
+
98
+ class Railtie < Rails::Railtie
99
+ extend Rails::Initializable::ClassMethods
100
+ end
101
+
102
+ class UninitializedAttribute < StandardError
103
+ end
93
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.1.1
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamajyotan
@@ -50,6 +50,7 @@ files:
50
50
  - lib/active_record_compose/attributes/querying.rb
51
51
  - lib/active_record_compose/callbacks.rb
52
52
  - lib/active_record_compose/composed_collection.rb
53
+ - lib/active_record_compose/exceptions.rb
53
54
  - lib/active_record_compose/inspectable.rb
54
55
  - lib/active_record_compose/model.rb
55
56
  - lib/active_record_compose/persistence.rb
@@ -83,7 +84,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
83
84
  - !ruby/object:Gem::Version
84
85
  version: '0'
85
86
  requirements: []
86
- rubygems_version: 3.7.2
87
+ rubygems_version: 4.0.3
87
88
  specification_version: 4
88
89
  summary: activemodel form object pattern
89
90
  test_files: []