active_record_compose 1.1.1 → 1.2.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: 491f46737a3744c5c5d3e0319de3ee9f64ae21a9adfab39f36cedc478bfa0e7c
4
- data.tar.gz: 406f4c471e0d7c40ec7b3a0d492f8e7e6c96d93fd4b43204a128bf52c1f7ed4c
3
+ metadata.gz: 2622aa32a886a2c21fcc7836254285cb92f1ce7745674297c3e7fe46bcbe556a
4
+ data.tar.gz: 747acd3e97cb78aba78b9d3f2c87ab4d9bd34b0727aaf2a1c94d9520ea9eaa08
5
5
  SHA512:
6
- metadata.gz: 9ff0e91cfb0ca22322d1b9ca47c32f09372afa206e9fa6690c1dfe5ca30d221269ccc55fe17556627234ea8a7019118ddec8a46ad24a52c47239e694dbcac323
7
- data.tar.gz: 925001a566ee81600ce91862c0c95f241dba2cd735f6ecbc3e987e83e6cd6f59d217e67afa4bed0a981f35c16645661ef7eeb00adb18df92999f9127e22116e0
6
+ metadata.gz: e6002732e8e6fa09269ccb4a7635af4ef683d06af14e829aa062846c6dbb90c2942d5b3cc2b4d715eeef2182498e732d33b5ae004836ff703f8d8235fec12c70
7
+ data.tar.gz: 18d3f9a635d3f4bf940eeb4020824fa7fc7d91cf27b0a99aa253af53e31fdca6bebd69d402537bea29749de26eba3f5a261e10a3ef2ebf079294e7fc3dbd5127
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.2.0] - 2026-01-05
4
+
5
+ * Avoid issuing multiple saves on the same object.
6
+ (https://github.com/hamajyotan/active_record_compose/pull/56)
7
+ * The storage of `#models` has been changed from an Array to a Set.
8
+ This prevents duplicate additions of the same object and option combinations.
9
+ Also, `#models#delete` now deletes the model regardless of the options used when it was added.
10
+ (https://github.com/hamajyotan/active_record_compose/pull/57)
11
+ * Adding an `ActiveRecordCompose::Model` to `#models` now throws an error if there is a circular reference.
12
+ (https://github.com/hamajyotan/active_record_compose/pull/58)
13
+
3
14
  ## [1.1.1] - 2025-12-04
4
15
 
5
16
  * fix: the save method would return nil instead of false.
@@ -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,29 @@
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
+ end
@@ -76,10 +76,24 @@ module ActiveRecordCompose
76
76
  #
77
77
  # @return [ActiveModel::Errors]
78
78
 
79
+ # @private
80
+ def detect_circular_reference(targets = [])
81
+ raise CircularReferenceDetected if targets.include?(object_id)
82
+
83
+ targets += [ object_id ]
84
+ # steep:ignore:start
85
+ models.select { _1.respond_to?(:detect_circular_reference) }.each do |m|
86
+ m.detect_circular_reference(targets)
87
+ end
88
+ # steep:ignore:end
89
+ end
90
+
79
91
  private
80
92
 
81
93
  # @private
82
94
  def validate_models
95
+ detect_circular_reference
96
+
83
97
  context = override_validation_context
84
98
  models.__wrapped_models.lazy.select { _1.invalid?(context) }.each { errors.merge!(_1) }
85
99
  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.0"
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`.
@@ -64,16 +64,20 @@ module ActiveRecordCompose
64
64
  class ComposedCollection
65
65
  def initialize: (Model) -> void
66
66
 
67
+ @symbol_proc_map: Hash[Symbol, (destroy_context_type | condition_type)]
68
+
67
69
  private
68
70
  attr_reader owner: Model
69
- attr_reader models: Array[WrappedModel]
71
+ attr_reader models: Set[WrappedModel]
70
72
  def wrap: (ar_like, ?destroy: (bool | Symbol | destroy_context_type), ?if: (nil | Symbol | condition_type)) -> WrappedModel
73
+ def symbol_proc_map: () -> Hash[Symbol, (destroy_context_type | condition_type)]
74
+ def instance_variables_to_inspect: () -> Array[Symbol]
71
75
 
72
76
  module PackagePrivate
73
- def __wrapped_models: () -> Array[WrappedModel]
77
+ def __wrapped_models: () -> Enumerable[WrappedModel]
74
78
 
75
79
  private
76
- def models: () -> Array[WrappedModel]
80
+ def models: () -> Set[WrappedModel]
77
81
  end
78
82
 
79
83
  include PackagePrivate
@@ -143,10 +147,6 @@ module ActiveRecordCompose
143
147
  def raise_on_save_error_message: -> String
144
148
  end
145
149
 
146
- class Railtie < Rails::Railtie
147
- extend Rails::Initializable::ClassMethods
148
- end
149
-
150
150
  module Validations : Model
151
151
  extend ActiveSupport::Concern
152
152
  extend ActiveModel::Validations::ClassMethods
@@ -154,6 +154,7 @@ module ActiveRecordCompose
154
154
  def save: (**untyped options) -> bool
155
155
  def save!: (**untyped options) -> untyped
156
156
  def valid?: (?validation_context context) -> bool
157
+ def detect_circular_reference: (?Array[Integer])-> untyped
157
158
 
158
159
  @context_for_override_validation: OverrideValidationContext
159
160
 
@@ -187,6 +188,7 @@ module ActiveRecordCompose
187
188
  attr_reader model: ar_like
188
189
  attr_reader destroy_context_type: (bool | destroy_context_type)
189
190
  attr_reader if_option: (nil | condition_type)
191
+ def equality_key: () -> [ar_like, (bool | destroy_context_type), (nil | condition_type)]
190
192
 
191
193
  module PackagePrivate
192
194
  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,8 @@ 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
93
101
  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.0
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: []