active_record_compose 1.1.0 → 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: 76bd8c66869f7b24c2938ecb72cf2c3b9f47cdcc34a8e1c87f57329f50880ecd
4
- data.tar.gz: f6efc5ec40f5e870064dec9928e56a3ee8a3ca07ea00bd5dcbe86f0855d440ce
3
+ metadata.gz: 2622aa32a886a2c21fcc7836254285cb92f1ce7745674297c3e7fe46bcbe556a
4
+ data.tar.gz: 747acd3e97cb78aba78b9d3f2c87ab4d9bd34b0727aaf2a1c94d9520ea9eaa08
5
5
  SHA512:
6
- metadata.gz: c0361c3c95394e550e8571ea4880545dd8e566e9a6d4c829eb494db90226c009487361ca7eed8e3691d6dbf59674e7fc2fa782b2db006b79113c95c5b229f95c
7
- data.tar.gz: 2a2ac6e1d86640c8d7df1448fc362e459790561a8223066351cadd7ea297e456bc093a015fb8a765e755deccbd5071e342fe035180a4c222ce81584305388085
6
+ metadata.gz: e6002732e8e6fa09269ccb4a7635af4ef683d06af14e829aa062846c6dbb90c2942d5b3cc2b4d715eeef2182498e732d33b5ae004836ff703f8d8235fec12c70
7
+ data.tar.gz: 18d3f9a635d3f4bf940eeb4020824fa7fc7d91cf27b0a99aa253af53e31fdca6bebd69d402537bea29749de26eba3f5a261e10a3ef2ebf079294e7fc3dbd5127
data/.yardopts CHANGED
@@ -1,4 +1,6 @@
1
1
  --private
2
+ --no-private
2
3
  --markup markdown
3
4
  --markup-provider redcarpet
5
+ --plugin activesupport-concern
4
6
  --default-return void
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
14
+ ## [1.1.1] - 2025-12-04
15
+
16
+ * fix: the save method would return nil instead of false.
17
+ * doc: We've simplified the documentation comment yard.
18
+
3
19
  ## [1.1.0] - 2025-11-19
4
20
 
5
21
  * Implemented ActiveRecord-like #inspect
@@ -5,8 +5,6 @@ require_relative "attributes/delegation"
5
5
  require_relative "attributes/querying"
6
6
 
7
7
  module ActiveRecordCompose
8
- # @private
9
- #
10
8
  # Provides attribute-related functionality for use within ActiveRecordCompose::Model.
11
9
  #
12
10
  # This module allows you to define attributes on your composed model, including support
@@ -56,45 +54,32 @@ module ActiveRecordCompose
56
54
  include ActiveModel::Attributes
57
55
 
58
56
  included do
57
+ # @type self: Class
58
+
59
59
  include Querying
60
60
 
61
- # @type self: Class
62
61
  class_attribute :delegated_attributes, instance_writer: false
63
62
  end
64
63
 
65
- module ClassMethods
66
- # Defines the reader and writer for the specified attribute.
67
- #
68
- # @example
69
- # class AccountRegistration < ActiveRecordCompose::Model
70
- # def initialize(account, attributes = {})
71
- # @account = account
72
- # super(attributes)
73
- # models.push(account)
74
- # end
75
- #
76
- # attribute :original_attribute, :string, default: "qux"
77
- # delegate_attribute :name, to: :account
78
- #
79
- # private
80
- #
81
- # attr_reader :account
82
- # end
83
- #
84
- # account = Account.new
85
- # account.name = "foo"
86
- #
87
- # registration = AccountRegistration.new(account)
88
- # registration.name # => "foo" (delegated)
89
- # registration.name? # => true (delegated attribute method + `?`)
90
- #
91
- # registration.name = "bar" # => updates account.name
92
- # account.name # => "bar"
93
- # account.name? # => true
64
+ # steep:ignore:start
65
+
66
+ class_methods do
67
+ # Provides a method of attribute access to the encapsulated model.
94
68
  #
95
- # registration.attributes
96
- # # => { "original_attribute" => "qux", "name" => "bar" }
69
+ # It provides a way to access the attributes of the model it encompasses,
70
+ # allowing transparent access as if it had those attributes itself.
97
71
  #
72
+ # @param [Array<Symbol, String>] attributes
73
+ # attributes A variable-length list of attribute names to delegate.
74
+ # @param [Symbol, String] to
75
+ # The target object to which attributes are delegated (keyword argument).
76
+ # @param [Boolean] allow_nil
77
+ # allow_nil Whether to allow nil values. Defaults to false.
78
+ # @example Basic usage
79
+ # delegate_attribute :name, :email, to: :profile
80
+ # @example Allowing nil
81
+ # delegate_attribute :bio, to: :profile, allow_nil: true
82
+ # @see Module#delegate for similar behavior in ActiveSupport
98
83
  def delegate_attribute(*attributes, to:, allow_nil: false)
99
84
  if to.start_with?("@")
100
85
  raise ArgumentError, "Instance variables cannot be specified in delegate to. (#{to})"
@@ -107,45 +92,68 @@ module ActiveRecordCompose
107
92
  end
108
93
 
109
94
  # Returns a array of attribute name.
110
- # Attributes declared with `delegate_attribute` are also merged.
95
+ # Attributes declared with {.delegate_attribute} are also merged.
111
96
  #
97
+ # @see #attribute_names
112
98
  # @return [Array<String>] array of attribute name.
113
99
  def attribute_names = super + delegated_attributes.to_a.map { _1.attribute_name }
114
100
  end
115
101
 
102
+ # steep:ignore:end
103
+
116
104
  # Returns a array of attribute name.
117
- # Attributes declared with `delegate_attribute` are also merged.
105
+ # Attributes declared with {.delegate_attribute} are also merged.
106
+ #
107
+ # class Foo < ActiveRecordCompose::Base
108
+ # def initialize(attributes = {})
109
+ # @account = Account.new
110
+ # super
111
+ # end
112
+ #
113
+ # attribute :confirmation, :boolean, default: false # plain attribute
114
+ # delegate_attribute :name, to: :account # delegated attribute
115
+ #
116
+ # private
117
+ #
118
+ # attr_reader :account
119
+ # end
118
120
  #
121
+ # Foo.attribute_names # Returns the merged state of plain and delegated attributes
122
+ # # => ["confirmation" ,"name"]
123
+ #
124
+ # foo = Foo.new
125
+ # foo.attribute_names # Similar behavior for instance method version
126
+ # # => ["confirmation", "name"]
127
+ #
128
+ # @see #attributes
119
129
  # @return [Array<String>] array of attribute name.
120
130
  def attribute_names = super + delegated_attributes.to_a.map { _1.attribute_name }
121
131
 
122
132
  # Returns a hash with the attribute name as key and the attribute value as value.
123
- # Attributes declared with `delegate_attribute` are also merged.
133
+ # Attributes declared with {.delegate_attribute} are also merged.
124
134
  #
125
- # @return [Hash] hash with the attribute name as key and the attribute value as value.
126
- # @example
127
- # class AccountRegistration < ActiveRecordCompose::Model
128
- # def initialize(account, attributes = {})
129
- # @account = account
130
- # super(attributes)
131
- # models.push(account)
132
- # end
135
+ # class Foo < ActiveRecordCompose::Base
136
+ # def initialize(attributes = {})
137
+ # @account = Account.new
138
+ # super
139
+ # end
133
140
  #
134
- # attribute :original_attribute, :string, default: "qux"
135
- # delegate_attribute :name, to: :account
141
+ # attribute :confirmation, :boolean, default: false # plain attribute
142
+ # delegate_attribute :name, to: :account # delegated attribute
136
143
  #
137
- # private
144
+ # private
138
145
  #
139
- # attr_reader :account
140
- # end
141
- #
142
- # account = Account.new
143
- # account.name = "foo"
146
+ # attr_reader :account
147
+ # end
144
148
  #
145
- # registration = AccountRegistration.new(account)
149
+ # foo = Foo.new
150
+ # foo.name = "Alice"
151
+ # foo.confirmation = true
146
152
  #
147
- # registration.attributes # => { "original_attribute" => "qux", "name" => "bar" }
153
+ # foo.attributes # Returns the merged state of plain and delegated attributes
154
+ # # => { "confirmation" => true, "name" => "Alice" }
148
155
  #
156
+ # @return [Hash<String, Object>] hash with the attribute name as key and the attribute value as value.
149
157
  def attributes
150
158
  super.merge(*delegated_attributes.to_a.map { _1.attribute_hash(self) })
151
159
  end
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordCompose
4
- # @private
5
- #
6
4
  # Provides hooks into the life cycle of an ActiveRecordCompose model,
7
5
  # allowing you to insert custom logic before or after changes to the object's state.
8
6
  #
@@ -21,18 +19,48 @@ module ActiveRecordCompose
21
19
  include ActiveModel::Validations::Callbacks
22
20
 
23
21
  included do
22
+ # @!method self.before_save(*args, &block)
23
+ # Registers a callback to be called before a model is saved.
24
+
25
+ # @!method self.around_save(*args, &block)
26
+ # Registers a callback to be called around the save of a model.
27
+
28
+ # @!method self.after_save(*args, &block)
29
+ # Registers a callback to be called after a model is saved.
30
+
24
31
  define_model_callbacks :save
32
+
33
+ # @!method self.before_create(*args, &block)
34
+ # Registers a callback to be called before a model is created.
35
+
36
+ # @!method self.around_create(*args, &block)
37
+ # Registers a callback to be called around the creation of a model.
38
+
39
+ # @!method self.after_create(*args, &block)
40
+ # Registers a callback to be called after a model is created.
41
+
25
42
  define_model_callbacks :create
43
+
44
+ # @!method self.before_update(*args, &block)
45
+ # Registers a callback to be called before a model is updated.
46
+
47
+ # @!method self.around_update(*args, &block)
48
+ # Registers a callback to be called around the update of a model.
49
+
50
+ # @!method self.after_update(*args, &block)
51
+ # Registers a callback to be called after a update is updated.
26
52
  define_model_callbacks :update
27
53
  end
28
54
 
29
55
  private
30
56
 
57
+ # @private
31
58
  # Evaluate while firing callbacks such as `before_save` `after_save`
32
59
  # before and after block evaluation.
33
60
  #
34
61
  def with_callbacks(&block) = run_callbacks(:save) { run_callbacks(callback_context, &block) }
35
62
 
63
+ # @private
36
64
  # Returns the symbol representing the callback context, which is `:create` if the record
37
65
  # is new, or `:update` if it has been persisted.
38
66
  #
@@ -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
@@ -4,8 +4,6 @@ require "active_support/parameter_filter"
4
4
  require_relative "attributes"
5
5
 
6
6
  module ActiveRecordCompose
7
- # @private
8
- #
9
7
  # It provides #inspect behavior.
10
8
  # It tries to replicate the inspect format provided by ActiveRecord as closely as possible.
11
9
  #
@@ -39,26 +37,48 @@ module ActiveRecordCompose
39
37
  extend ActiveSupport::Concern
40
38
  include ActiveRecordCompose::Attributes
41
39
 
40
+ # steep:ignore:start
41
+
42
+ # @private
43
+ FILTERED_MASK =
44
+ Class.new(DelegateClass(::String)) do
45
+ def pretty_print(pp)
46
+ pp.text __getobj__
47
+ end
48
+ end.new(ActiveSupport::ParameterFilter::FILTERED).freeze
49
+ private_constant :FILTERED_MASK
50
+
51
+ # steep:ignore:end
52
+
42
53
  included do
43
54
  self.filter_attributes = []
44
55
  end
45
56
 
46
- module ClassMethods
57
+ # steep:ignore:start
58
+
59
+ class_methods do
60
+ # Returns columns not to expose when invoking {#inspect}.
61
+ #
62
+ # @return [Array<Symbol>]
63
+ # @see #inspect
47
64
  def filter_attributes
48
65
  if @filter_attributes.nil?
49
- superclass.filter_attributes # steep:ignore
66
+ superclass.filter_attributes
50
67
  else
51
68
  @filter_attributes
52
69
  end
53
70
  end
54
71
 
72
+ # Specify columns not to expose when invoking {#inspect}.
73
+ #
74
+ # @param [Array<Symbol>] value
75
+ # @see #inspect
55
76
  def filter_attributes=(value)
56
77
  @inspection_filter = nil
57
78
  @filter_attributes = value
58
79
  end
59
80
 
60
- # steep:ignore:start
61
-
81
+ # @private
62
82
  def inspection_filter
63
83
  if @filter_attributes.nil?
64
84
  superclass.inspection_filter
@@ -77,20 +97,41 @@ module ActiveRecordCompose
77
97
  @filter_attributes ||= nil
78
98
  end
79
99
  end
80
-
81
- FILTERED_MASK =
82
- Class.new(DelegateClass(::String)) do
83
- def pretty_print(pp)
84
- pp.text __getobj__
85
- end
86
- end.new(ActiveSupport::ParameterFilter::FILTERED).freeze
87
- private_constant :FILTERED_MASK
88
-
89
- # steep:ignore:end
90
100
  end
91
101
 
102
+ # steep:ignore:end
103
+
92
104
  # Returns a formatted string representation of the record's attributes.
105
+ # It tries to replicate the inspect format provided by ActiveRecord as closely as possible.
106
+ #
107
+ # @example
108
+ # class Model < ActiveRecordCompose::Model
109
+ # def initialize(ar_model)
110
+ # @ar_model = ar_model
111
+ # super
112
+ # end
113
+ #
114
+ # attribute :foo, :date, default: -> { Date.today }
115
+ # delegate_attribute :bar, to: :ar_model
116
+ #
117
+ # private attr_reader :ar_model
118
+ # end
119
+ #
120
+ # m = Model.new(ar_model)
121
+ # m.inspect #=> #<Model:0x00007ff0fe75fe58 foo: "2025-11-14", bar: "bar">
122
+ #
123
+ # @example use {.filter_attributes}
124
+ # class Model < ActiveRecordCompose::Model
125
+ # self.filter_attributes += %i[foo]
126
+ #
127
+ # # ...
128
+ # end
129
+ #
130
+ # m = Model.new(ar_model)
131
+ # m.inspect #=> #<Model:0x00007ff0fe75fe58 foo: [FILTERED], bar: "bar">
93
132
  #
133
+ # @return [String] formatted string representation of the record's attributes.
134
+ # @see .filter_attributes
94
135
  def inspect
95
136
  inspection =
96
137
  if @attributes
@@ -126,6 +167,7 @@ module ActiveRecordCompose
126
167
 
127
168
  private
128
169
 
170
+ # @private
129
171
  def format_for_inspect(name, value)
130
172
  return value.inspect if value.nil?
131
173