active_record_compose 0.11.2 → 0.11.3

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: 2e0428468971c6a369fd86cfae9b5413acddf18a252a1f502498e11a55e23b92
4
- data.tar.gz: fea23f0f94a09d84b462e27af4feb01c1e40f0c8ff4ba306a9dd391f89eeb9f7
3
+ metadata.gz: 154ee35310ede836e5d89ecd703efadbbc2b7fba0a6229797e9b38b5288eba47
4
+ data.tar.gz: 42c565c5093680ae089bac356541561dc83ff73759941f268af22365338126db
5
5
  SHA512:
6
- metadata.gz: f293d9167fe2a1f879df2f3936e784a2f6f33b1df20160606a5ef65a23f2e4a81040ada14ed7d42aa1ed289899db278880945f75d8a792f369a02f5301e31a5f
7
- data.tar.gz: b7db34b71359f1f7d90bb6bbb25b5139595742b202c29b8f009e506dd2244a485f80151fd74edf5245aca224ef6a58bf9c507ade5b95bc69b84963bf3203f9a9
6
+ metadata.gz: a5a9f0a46534ea0dbb3423275a68b521543c67074d8fcd7c637d46f86fcc42f93ac6792b2d80f83b6a0aae367f6dd461e6fb4d0e06730f40da3aefd0751393e9
7
+ data.tar.gz: c5bb3aaae6e84b23bc264ce5a1ad7b369f248c1fb1f1494b2753a1a82681ecc3df8c5f10a6b68854cda319c65da59e69435dd1c4381f44ff585d81a2400b5bac
data/.yardopts ADDED
@@ -0,0 +1,4 @@
1
+ --private
2
+ --markup markdown
3
+ --markup-provider redcarpet
4
+ --default-return void
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.11.3] - 2025-07-13
4
+
5
+ - refactor: Aggregation attribute module.
6
+ (https://github.com/hamajyotan/active_record_compose/pull/24)
7
+ - Warn against specifying instance variables, etc. directly in the `:to` option of `delegate_attribute`.
8
+ - Deprecated:
9
+ ```ruby
10
+ delegate_attribute :foo, to: :@model
11
+ ```
12
+ - Recommended:
13
+ ```ruby
14
+ delegate_attribute :foo, to: :model
15
+ private
16
+ attr_reader :model
17
+ ```
18
+ - doc: Expansion of yard documentation comments.
19
+
3
20
  ## [0.11.2] - 2025-06-29
4
21
 
5
22
  - `ActiveModel::Attributes.attribute_names` now takes into account attributes declared in `.delegate_attribute`
data/README.md CHANGED
@@ -381,20 +381,20 @@ end
381
381
  ```ruby
382
382
  r = Registration.new(name: 'foo', email: 'example@example.com', accept: false)
383
383
  r.valid?
384
- => true
384
+ #=> true
385
385
 
386
386
  r.valid?(:education)
387
- => false
387
+ #=> false
388
388
  r.errors.map { [_1.attribute, _1.type] }
389
- => [[:email, :invalid], [:accept, :blank]]
389
+ #=> [[:email, :invalid], [:accept, :blank]]
390
390
 
391
391
  r.email = 'example@example.edu'
392
392
  r.accept = true
393
393
 
394
394
  r.valid?(:education)
395
- => true
395
+ #=> true
396
396
  r.save(context: :education)
397
- => true
397
+ #=> true
398
398
  ```
399
399
 
400
400
  ## Sample application as an example
@@ -405,6 +405,7 @@ With Github Codespaces, it can also be run directly in the browser. Naturally, a
405
405
 
406
406
  ## Links
407
407
 
408
+ - [Document from YARD](https://hamajyotan.github.io/active_record_compose/)
408
409
  - [Smart way to update multiple models simultaneously in Rails](https://dev.to/hamajyotan/smart-way-to-update-multiple-models-simultaneously-in-rails-51b6)
409
410
 
410
411
  ## Development
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordCompose
4
+ module Attributes
5
+ # @private
6
+ class Delegation
7
+ # @return [Symbol] The attribute name as symbol
8
+ attr_reader :attribute
9
+
10
+ def initialize(attribute:, to:, allow_nil: false)
11
+ @attribute = attribute.to_sym
12
+ @to = to.to_sym
13
+ @allow_nil = !!allow_nil
14
+
15
+ freeze
16
+ end
17
+
18
+ def define_delegated_attribute(klass)
19
+ klass.delegate(reader, writer, to:, allow_nil:)
20
+ klass.define_attribute_methods(attribute)
21
+ end
22
+
23
+ # @return [String] The attribute name as string
24
+ def attribute_name = attribute.to_s
25
+
26
+ # @return [Hash<String, Object>]
27
+ def attribute_hash(model)
28
+ { attribute_name => model.public_send(attribute) }
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :to, :allow_nil
34
+
35
+ def reader = attribute.to_s
36
+
37
+ def writer = "#{attribute}="
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordCompose
4
+ module Attributes
5
+ # @private
6
+ # This provides predicate methods based on the attributes.
7
+ #
8
+ # @example
9
+ # class AccountRegistration < ActiveRecordCompose::Model
10
+ # def initialize
11
+ # @account = Account.new
12
+ # super()
13
+ # models << account
14
+ # end
15
+ #
16
+ # attribute :original_attr
17
+ # delegate_attribute :name, :email, to: :account
18
+ #
19
+ # private
20
+ #
21
+ # attr_reader :account
22
+ # end
23
+ #
24
+ # model = AccountRegistration.new
25
+ #
26
+ # model.name #=> nil
27
+ # model.name? #=> false
28
+ # model.name = "Alice"
29
+ # model.name? #=> true
30
+ #
31
+ # model.original_attr = "Bob"
32
+ # model.original_attr? #=> true
33
+ # model.original_attr = ""
34
+ # model.original_attr? #=> false
35
+ #
36
+ # # If the value is numeric, it returns the result of checking whether it is zero or not.
37
+ # # This behavior is consistent with `ActiveRecord::AttributeMethods::Query`.
38
+ # model.original_attr = 123
39
+ # model.original_attr? #=> true
40
+ # model.original_attr = 0
41
+ # model.original_attr? #=> false
42
+ #
43
+ module Querying
44
+ extend ActiveSupport::Concern
45
+ include ActiveModel::AttributeMethods
46
+
47
+ included do
48
+ attribute_method_suffix "?", parameters: false
49
+ end
50
+
51
+ private
52
+
53
+ def attribute?(attr_name)
54
+ value = public_send(attr_name)
55
+
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
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "attributes/delegation"
4
+ require_relative "attributes/querying"
5
+
6
+ module ActiveRecordCompose
7
+ # @private
8
+ #
9
+ # Provides attribute-related functionality for use within ActiveRecordCompose::Model.
10
+ #
11
+ # This module allows you to define attributes on your composed model, including support
12
+ # for query methods (e.g., `#attribute?`) and delegation of attributes to underlying
13
+ # ActiveRecord instances via macros.
14
+ #
15
+ # For example, `.delegate_attribute` defines attribute accessors that delegate to
16
+ # a specific model, similar to:
17
+ #
18
+ # delegate :name, :name=, to: :account
19
+ #
20
+ # Additionally, delegated attributes are included in the composed model's `#attributes`
21
+ # hash.
22
+ #
23
+ # @example
24
+ # class AccountRegistration < ActiveRecordCompose::Model
25
+ # def initialize(account, attributes = {})
26
+ # @account = account
27
+ # super(attributes)
28
+ # models.push(account)
29
+ # end
30
+ #
31
+ # attribute :original_attribute, :string, default: "qux"
32
+ # delegate_attribute :name, to: :account
33
+ #
34
+ # private
35
+ #
36
+ # attr_reader :account
37
+ # end
38
+ #
39
+ # account = Account.new
40
+ # account.name = "foo"
41
+ #
42
+ # registration = AccountRegistration.new(account)
43
+ # registration.name # => "foo" (delegated)
44
+ # registration.name? # => true (delegated attribute method + `?`)
45
+ #
46
+ # registration.name = "bar" # => updates account.name
47
+ # account.name # => "bar"
48
+ # account.name? # => true
49
+ #
50
+ # registration.attributes
51
+ # # => { "original_attribute" => "qux", "name" => "bar" }
52
+ #
53
+ module Attributes
54
+ extend ActiveSupport::Concern
55
+ include ActiveModel::Attributes
56
+
57
+ included do
58
+ include Querying
59
+
60
+ # @type self: Class
61
+ class_attribute :delegated_attributes, instance_writer: false
62
+ end
63
+
64
+ module ClassMethods
65
+ ALLOW_NIL_DEFAULT = Object.new.freeze # steep:ignore
66
+ private_constant :ALLOW_NIL_DEFAULT
67
+
68
+ # Defines the reader and writer for the specified attribute.
69
+ #
70
+ # @example
71
+ # class AccountRegistration < ActiveRecordCompose::Model
72
+ # def initialize(account, attributes = {})
73
+ # @account = account
74
+ # super(attributes)
75
+ # models.push(account)
76
+ # end
77
+ #
78
+ # attribute :original_attribute, :string, default: "qux"
79
+ # delegate_attribute :name, to: :account
80
+ #
81
+ # private
82
+ #
83
+ # attr_reader :account
84
+ # end
85
+ #
86
+ # account = Account.new
87
+ # account.name = "foo"
88
+ #
89
+ # registration = AccountRegistration.new(account)
90
+ # registration.name # => "foo" (delegated)
91
+ # registration.name? # => true (delegated attribute method + `?`)
92
+ #
93
+ # registration.name = "bar" # => updates account.name
94
+ # account.name # => "bar"
95
+ # account.name? # => true
96
+ #
97
+ # registration.attributes
98
+ # # => { "original_attribute" => "qux", "name" => "bar" }
99
+ #
100
+ def delegate_attribute(*attributes, to:, allow_nil: ALLOW_NIL_DEFAULT) # steep:ignore
101
+ # steep:ignore:start
102
+ 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)
125
+ end
126
+ allow_nil = false if allow_nil == ALLOW_NIL_DEFAULT
127
+ # steep:ignore:end
128
+
129
+ delegations = attributes.map { Delegation.new(attribute: _1, to:, allow_nil:) }
130
+ delegations.each { _1.define_delegated_attribute(self) }
131
+
132
+ self.delegated_attributes = (delegated_attributes.to_a + delegations).reverse.uniq { _1.attribute }.reverse
133
+ end
134
+
135
+ # Returns a array of attribute name.
136
+ # Attributes declared with `delegate_attribute` are also merged.
137
+ #
138
+ # @return [Array<String>] array of attribute name.
139
+ def attribute_names = super + delegated_attributes.to_a.map { _1.attribute_name }
140
+ end
141
+
142
+ # Returns a array of attribute name.
143
+ # Attributes declared with `delegate_attribute` are also merged.
144
+ #
145
+ # @return [Array<String>] array of attribute name.
146
+ def attribute_names = super + delegated_attributes.to_a.map { _1.attribute_name }
147
+
148
+ # Returns a hash with the attribute name as key and the attribute value as value.
149
+ # Attributes declared with `delegate_attribute` are also merged.
150
+ #
151
+ # @return [Hash] hash with the attribute name as key and the attribute value as value.
152
+ # @example
153
+ # class AccountRegistration < ActiveRecordCompose::Model
154
+ # def initialize(account, attributes = {})
155
+ # @account = account
156
+ # super(attributes)
157
+ # models.push(account)
158
+ # end
159
+ #
160
+ # attribute :original_attribute, :string, default: "qux"
161
+ # delegate_attribute :name, to: :account
162
+ #
163
+ # private
164
+ #
165
+ # attr_reader :account
166
+ # end
167
+ #
168
+ # account = Account.new
169
+ # account.name = "foo"
170
+ #
171
+ # registration = AccountRegistration.new(account)
172
+ #
173
+ # registration.attributes # => { "original_attribute" => "qux", "name" => "bar" }
174
+ #
175
+ def attributes
176
+ super.merge(*delegated_attributes.to_a.map { _1.attribute_hash(self) })
177
+ end
178
+ end
179
+ end
@@ -1,11 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "validations"
4
+
3
5
  module ActiveRecordCompose
6
+ # @private
7
+ #
8
+ # Provides hooks into the life cycle of an ActiveRecordCompose model,
9
+ # allowing you to insert custom logic before or after changes to the object's state.
10
+ #
11
+ # The callback flow generally follows the same structure as Active Record:
12
+ #
13
+ # * `before_validation`
14
+ # * `after_validation`
15
+ # * `before_save`
16
+ # * `before_create` (or `before_update` for update operations)
17
+ # * `after_create` (or `after_update` for update operations)
18
+ # * `after_save`
19
+ # * `after_commit` (or `after_rollback` when the transaction is rolled back)
20
+ #
4
21
  module Callbacks
5
22
  extend ActiveSupport::Concern
6
23
  include ActiveModel::Validations::Callbacks
7
24
 
8
25
  included do
26
+ include ActiveRecordCompose::Validations
27
+
9
28
  define_model_callbacks :save
10
29
  define_model_callbacks :create
11
30
  define_model_callbacks :update
@@ -13,8 +32,15 @@ module ActiveRecordCompose
13
32
 
14
33
  private
15
34
 
35
+ # Evaluate while firing callbacks such as `before_save` `after_save`
36
+ # before and after block evaluation.
37
+ #
16
38
  def with_callbacks(&block) = run_callbacks(:save) { run_callbacks(callback_context, &block) }
17
39
 
40
+ # Returns the symbol representing the callback context, which is `:create` if the record
41
+ # is new, or `:update` if it has been persisted.
42
+ #
43
+ # @return [:create, :update] either `:create` if not persisted, or `:update` if persisted
18
44
  def callback_context = persisted? ? :update : :create
19
45
  end
20
46
  end
@@ -5,6 +5,9 @@ require_relative "wrapped_model"
5
5
  module ActiveRecordCompose
6
6
  using WrappedModel::PackagePrivate
7
7
 
8
+ # Object obtained by {ActiveRecordCompose::Model#models}.
9
+ #
10
+ # It functions as a collection that contains the object to be saved.
8
11
  class ComposedCollection
9
12
  include Enumerable
10
13
 
@@ -15,7 +18,7 @@ module ActiveRecordCompose
15
18
 
16
19
  # Enumerates model objects.
17
20
  #
18
- # @yieldparam [Object] the model instance
21
+ # @yieldparam [Object] model model instance
19
22
  # @return [Enumerator] when not block given.
20
23
  # @return [self] when block given, returns itself.
21
24
  def each
@@ -27,7 +30,7 @@ module ActiveRecordCompose
27
30
 
28
31
  # Appends model to collection.
29
32
  #
30
- # @param model [Object] the model instance
33
+ # @param model [Object] model instance
31
34
  # @return [self] returns itself.
32
35
  def <<(model)
33
36
  models << wrap(model, destroy: false)
@@ -36,12 +39,14 @@ module ActiveRecordCompose
36
39
 
37
40
  # Appends model to collection.
38
41
  #
39
- # @param model [Object] the model instance
40
- # @param destroy [Boolean] given true, destroy model.
41
- # @param destroy [Proc] when proc returning true, destroy model.
42
- # @param destroy [Symbol] applies boolean value of result of sending a message to `owner` to evaluation.
43
- # @param if [Proc] evaluation result is false, it will not be included in the renewal.
44
- # @param if [Symbol] applies boolean value of result of sending a message to `owner` to evaluation.
42
+ # @param model [Object] model instance
43
+ # @param destroy [Boolean, Proc, Symbol] Controls whether the model should be destroyed.
44
+ # - Boolean: if `true`, the model will be destroyed.
45
+ # - Proc: the model will be destroyed if the proc returns `true`.
46
+ # - Symbol: sends the symbol as a method to `owner`; if the result is truthy, the model will be destroyed.
47
+ # @param if [Proc, Symbol] Controls conditional inclusion in renewal.
48
+ # - Proc: the proc is called, and if it returns `false`, the model is excluded.
49
+ # - Symbol: sends the symbol as a method to `owner`; if the result is falsy, the model is excluded.
45
50
  # @return [self] returns itself.
46
51
  def push(model, destroy: false, if: nil)
47
52
  models << wrap(model, destroy:, if:)
@@ -64,7 +69,7 @@ module ActiveRecordCompose
64
69
  # Removes the specified model from the collection.
65
70
  # Returns nil if the deletion fails, self if it succeeds.
66
71
  #
67
- # @param model [Object] the model instance
72
+ # @param model [Object] model instance
68
73
  # @return [self] Successful deletion
69
74
  # @return [nil] If deletion fails
70
75
  def delete(model)
@@ -76,8 +81,10 @@ module ActiveRecordCompose
76
81
 
77
82
  private
78
83
 
84
+ # @private
79
85
  attr_reader :owner, :models
80
86
 
87
+ # @private
81
88
  def wrap(model, destroy: false, if: nil)
82
89
  if destroy.is_a?(Symbol)
83
90
  method = destroy
@@ -1,39 +1,375 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "attributes"
3
4
  require_relative "callbacks"
4
- require_relative "attribute_querying"
5
5
  require_relative "composed_collection"
6
- require_relative "delegate_attribute"
7
- require_relative "transaction_support"
8
6
  require_relative "persistence"
9
- require_relative "validations"
10
7
 
11
8
  module ActiveRecordCompose
9
+ # This is the core class of {ActiveRecordCompose}.
10
+ #
11
+ # By defining subclasses of this model, you can use ActiveRecordCompose functionality in your application.
12
+ # It has the basic functionality of `ActiveModel::Model` and `ActiveModel::Attributes`,
13
+ # and also provides aggregation of multiple models and atomic updates through transaction control.
14
+ # @example Example of model registration.
15
+ # class AccountRegistration < ActiveRecordCompose::Model
16
+ # def initialize(account = Account.new, attributes = {})
17
+ # @account = account
18
+ # @profile = @account.build_profile
19
+ # models << account << profile
20
+ # super(attributes)
21
+ # end
22
+ #
23
+ # attribute :register_confirmation, :boolean, default: false
24
+ # delegate_attribute :name, :email, to: :account
25
+ # delegate_attribute :firstname, :lastname, :age, to: :profile
26
+ #
27
+ # validates :register_confirmation, presence: true
28
+ #
29
+ # private
30
+ #
31
+ # attr_reader :account, :profile
32
+ # end
33
+ # @example Multiple model update once.
34
+ # registration = AccountRegistration.new
35
+ # registration.assign_attributes(
36
+ # name: "alice-in-wonderland",
37
+ # email: "alice@example.com",
38
+ # firstname: "Alice",
39
+ # lastname: "Smith",
40
+ # age: 24,
41
+ # register_confirmation: true
42
+ # )
43
+ #
44
+ # registration.save! # Register Account and Profile models at the same time.
45
+ # Account.count # => (0 ->) 1
46
+ # Profile.count # => (0 ->) 1
47
+ # @example Attribute delegation.
48
+ # account = Account.new
49
+ # account.name = "foo"
50
+ #
51
+ # registration = AccountRegistration.new(account)
52
+ # registration.name # => "foo" (delegated)
53
+ # registration.name? # => true (delegated attribute method + `?`)
54
+ #
55
+ # registration.name = "bar" # => updates account.name
56
+ # account.name # => "bar"
57
+ # account.name? # => true
58
+ #
59
+ # registration.attributes # => { "original_attribute" => "qux", "name" => "bar" }
60
+ # @example Aggregate errors on invalid.
61
+ # registration = AccountRegistration.new
62
+ #
63
+ # registration.name = "alice-in-wonderland"
64
+ # registration.firstname = "Alice"
65
+ # registration.age = 18
66
+ #
67
+ # registration.valid?
68
+ # #=> false
69
+ #
70
+ # # The error contents of the objects stored in models are aggregated.
71
+ # # For example, direct access to errors in Account#email.
72
+ # registration.errors[:email].to_a # Account#email
73
+ # #=> ["can't be blank"]
74
+ #
75
+ # # Of course, the validation defined for itself is also working.
76
+ # registration.errors[:register_confirmation].to_a
77
+ # #=> ["can't be blank"]
78
+ #
79
+ # registration.errors.to_a
80
+ # #=> ["Email can't be blank", "Lastname can't be blank", "Register confirmation can't be blank"]
12
81
  class Model
13
82
  include ActiveModel::Model
14
- include ActiveModel::Attributes
15
83
 
16
- include ActiveRecordCompose::AttributeQuerying
17
- include ActiveRecordCompose::Callbacks
18
- include ActiveRecordCompose::DelegateAttribute
84
+ include ActiveRecordCompose::Attributes
19
85
  include ActiveRecordCompose::Persistence
20
- include ActiveRecordCompose::Validations
86
+ include ActiveRecordCompose::Callbacks
87
+
88
+ begin
89
+ # @group Model Core
90
+
91
+ # @!method self.delegate_attribute(*attributes, to:, allow_nil: false)
92
+ # Provides a method of attribute access to the encapsulated model.
93
+ #
94
+ # It provides a way to access the attributes of the model it encompasses,
95
+ # allowing transparent access as if it had those attributes itself.
96
+ #
97
+ # @param [Array<Symbol, String>] attributes
98
+ # attributes A variable-length list of attribute names to delegate.
99
+ # @param [Symbol, String] to
100
+ # The target object to which attributes are delegated (keyword argument).
101
+ # @param [Boolean] allow_nil
102
+ # allow_nil Whether to allow nil values. Defaults to false.
103
+ # @example Basic usage
104
+ # delegate_attribute :name, :email, to: :profile
105
+ # @example Allowing nil
106
+ # delegate_attribute :bio, to: :profile, allow_nil: true
107
+ # @see Module#delegate for similar behavior in ActiveSupport
108
+
109
+ # @!method self.attribute_names
110
+ # Returns a array of attribute name.
111
+ # Attributes declared with {.delegate_attribute} are also merged.
112
+ #
113
+ # @see #attribute_names
114
+ # @return [Array<String>] array of attribute name.
115
+
116
+ # @!method attribute_names
117
+ # Returns a array of attribute name.
118
+ # Attributes declared with {.delegate_attribute} are also merged.
119
+ #
120
+ # class Foo < ActiveRecordCompose::Base
121
+ # def initialize(attributes = {})
122
+ # @account = Account.new
123
+ # super
124
+ # end
125
+ #
126
+ # attribute :confirmation, :boolean, default: false # plain attribute
127
+ # delegate_attribute :name, to: :account # delegated attribute
128
+ #
129
+ # private
130
+ #
131
+ # attr_reader :account
132
+ # end
133
+ #
134
+ # Foo.attribute_names # Returns the merged state of plain and delegated attributes
135
+ # # => ["confirmation" ,"name"]
136
+ #
137
+ # foo = Foo.new
138
+ # foo.attribute_names # Similar behavior for instance method version
139
+ # # => ["confirmation", "name"]
140
+ #
141
+ # @see #attributes
142
+ # @return [Array<String>] array of attribute name.
143
+
144
+ # @!method attributes
145
+ # Returns a hash with the attribute name as key and the attribute value as value.
146
+ # Attributes declared with {.delegate_attribute} are also merged.
147
+ #
148
+ # class Foo < ActiveRecordCompose::Base
149
+ # def initialize(attributes = {})
150
+ # @account = Account.new
151
+ # super
152
+ # end
153
+ #
154
+ # attribute :confirmation, :boolean, default: false # plain attribute
155
+ # delegate_attribute :name, to: :account # delegated attribute
156
+ #
157
+ # private
158
+ #
159
+ # attr_reader :account
160
+ # end
161
+ #
162
+ # foo = Foo.new
163
+ # foo.name = "Alice"
164
+ # foo.confirmation = true
165
+ #
166
+ # foo.attributes # Returns the merged state of plain and delegated attributes
167
+ # # => { "confirmation" => true, "name" => "Alice" }
168
+ #
169
+ # @return [Hash<String, Object>] hash with the attribute name as key and the attribute value as value.
170
+
171
+ # @!method persisted?
172
+ # Returns true if model is persisted.
173
+ #
174
+ # By overriding this definition, you can control the callbacks that are triggered when a save is made.
175
+ # For example, returning false will trigger before_create, around_create and after_create,
176
+ # and returning true will trigger {.before_update}, {.around_update} and {.after_update}.
177
+ #
178
+ # @return [Boolean] returns true if model is persisted.
179
+ # @example
180
+ # # A model where persistence is always false
181
+ # class Foo < ActiveRecordCompose::Model
182
+ # before_save { puts "before_save called" }
183
+ # before_create { puts "before_create called" }
184
+ # before_update { puts "before_update called" }
185
+ # after_update { puts "after_update called" }
186
+ # after_create { puts "after_create called" }
187
+ # after_save { puts "after_save called" }
188
+ #
189
+ # def persisted? = false
190
+ # end
191
+ #
192
+ # # A model where persistence is always true
193
+ # class Bar < Foo
194
+ # def persisted? = true
195
+ # end
196
+ #
197
+ # Foo.new.save!
198
+ # # before_save called
199
+ # # before_create called
200
+ # # after_create called
201
+ # # after_save called
202
+ #
203
+ # Bar.new.save!
204
+ # # before_save called
205
+ # # before_update called
206
+ # # after_update called
207
+ # # after_save called
208
+
209
+ # @endgroup
210
+
211
+ # @group Validations
212
+
213
+ # @!method valid?(context = nil)
214
+ # Runs all the validations and returns the result as true or false.
215
+ # @param context Validation context.
216
+ # @return [Boolean] true on success, false on failure.
217
+
218
+ # @!method validate(context = nil)
219
+ # Alias for {#valid?}
220
+ # @see #valid? Validation context.
221
+ # @param context
222
+ # @return [Boolean] true on success, false on failure.
223
+
224
+ # @!method validate!(context = nil)
225
+ # @see #valid?
226
+ # Runs all the validations within the specified context.
227
+ # no errors are found, raises `ActiveRecord::RecordInvalid` otherwise.
228
+ # @param context Validation context.
229
+ # @raise ActiveRecord::RecordInvalid
230
+
231
+ # @!method errors
232
+ # Returns the `ActiveModel::Errors` object that holds all information about attribute error messages.
233
+ #
234
+ # The `ActiveModel::Base` implementation itself,
235
+ # but also aggregates error information for objects stored in {#models} when validation is performed.
236
+ #
237
+ # class Account < ActiveRecord::Base
238
+ # validates :name, :email, presence: true
239
+ # end
240
+ #
241
+ # class AccountRegistration < ActiveRecordCompose::Model
242
+ # def initialize(attributes = {})
243
+ # @account = Account.new
244
+ # super(attributes)
245
+ # models << account
246
+ # end
247
+ #
248
+ # attribute :confirmation, :boolean, default: false
249
+ # validates :confirmation, presence: true
250
+ #
251
+ # private
252
+ #
253
+ # attr_reader :account
254
+ # end
255
+ #
256
+ # registration = AccountRegistration
257
+ # registration.valid?
258
+ # #=> false
259
+ #
260
+ # # In addition to the model's own validation error information (`confirmation`), also aggregates
261
+ # # error information for objects stored in `account` (`name`, `email`) when validation is performed.
262
+ #
263
+ # registration.errors.map { _1.attribute } #=> [:name, :email, :confirmation]
264
+ #
265
+ # @return [ActiveModel::Errors]
266
+
267
+ # @endgroup
268
+
269
+ # @group Persistences
270
+
271
+ # @!method save(**options)
272
+ # Save the models that exist in models.
273
+ # Returns false if any of the targets fail, true if all succeed.
274
+ #
275
+ # The save is performed within a single transaction.
276
+ #
277
+ # Only the `:validate` option takes effect as it is required internally.
278
+ # However, we do not recommend explicitly specifying `validate: false` to skip validation.
279
+ # Additionally, the `:context` option is not accepted.
280
+ # The need for such a value indicates that operations from multiple contexts are being processed.
281
+ # If the contexts differ, we recommend separating them into different model definitions.
282
+ #
283
+ # @params [Hash] Optional parameters.
284
+ # @option options [Boolean] :validate Whether to run validations.
285
+ # This option is intended for internal use only.
286
+ # Users should avoid explicitly passing <tt>validate: false</tt>,
287
+ # as skipping validations can lead to unexpected behavior.
288
+ # @return [Boolean] returns true on success, false on failure.
289
+
290
+ # @!method save!(**options)
291
+ # Behavior is same to {#save}, but raises an exception prematurely on failure.
292
+ # @see #save
293
+ # @raise ActiveRecord::RecordInvalid
294
+ # @raise ActiveRecord::RecordNotSaved
295
+
296
+ # @!method update(attributes)
297
+ # Assign attributes and {#save}.
298
+ #
299
+ # @param [Hash<String, Object>] attributes
300
+ # new attributes.
301
+ # @see #save
302
+ # @return [Boolean] returns true on success, false on failure.
303
+
304
+ # @!method update!(attributes)
305
+ # Behavior is same to {#update}, but raises an exception prematurely on failure.
306
+ #
307
+ # @param [Hash<String, Object>] attributes
308
+ # new attributes.
309
+ # @see #save
310
+ # @see #update
311
+ # @raise ActiveRecord::RecordInvalid
312
+ # @raise ActiveRecord::RecordNotSaved
313
+
314
+ # @endgroup
315
+
316
+ # @group Callbacks
317
+
318
+ # @!method self.before_save(*args, &block)
319
+ # Registers a callback to be called before a model is saved.
320
+
321
+ # @!method self.around_save(*args, &block)
322
+ # Registers a callback to be called around the save of a model.
323
+
324
+ # @!method self.after_save(*args, &block)
325
+ # Registers a callback to be called after a model is saved.
326
+
327
+ # @!method self.before_create(*args, &block)
328
+ # Registers a callback to be called before a model is created.
329
+
330
+ # @!method self.around_create(*args, &block)
331
+ # Registers a callback to be called around the creation of a model.
332
+
333
+ # @!method self.after_create(*args, &block)
334
+ # Registers a callback to be called after a model is created.
335
+
336
+ # @!method self.before_update(*args, &block)
337
+ # Registers a callback to be called before a model is updated.
338
+
339
+ # @!method self.around_update(*args, &block)
340
+ # Registers a callback to be called around the update of a model.
341
+
342
+ # @!method self.after_update(*args, &block)
343
+ # Registers a callback to be called after a update is updated.
344
+
345
+ # @!method self.after_commit(*args, &block)
346
+ # Registers a block to be called after the transaction is fully committed.
347
+
348
+ # @!method self.after_rollback(*args, &block)
349
+ # Registers a block to be called after the transaction is rolled back.
350
+
351
+ # @endgroup
352
+ end
353
+
354
+ # @group Model Core
21
355
 
22
356
  def initialize(attributes = {})
23
357
  super
24
358
  end
25
359
 
26
- # Returns true if model is persisted.
27
- #
28
- # By overriding this definition, you can control the callbacks that are triggered when a save is made.
29
- # For example, returning false will trigger before_create, around_create and after_create,
30
- # and returning true will trigger before_update, around_update and after_update.
31
- #
32
- # @return [Boolean] returns true if model is persisted.
33
- def persisted? = super
34
-
35
360
  private
36
361
 
362
+ # Returns a collection of model elements to encapsulate.
363
+ # @example Adding models
364
+ # models << inner_model_a << inner_model_b
365
+ # models.push(inner_model_c)
366
+ # @example `#push` can have `:destroy` `:if` options
367
+ # models.push(profile, destroy: :blank_profile?)
368
+ # models.push(profile, destroy: -> { blank_profile? })
369
+ # @return [ActiveRecordCompose::ComposedCollection]
370
+ #
37
371
  def models = @__models ||= ActiveRecordCompose::ComposedCollection.new(self)
372
+
373
+ # @endgroup
38
374
  end
39
375
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "composed_collection"
4
+ require_relative "transaction_support"
4
5
 
5
6
  module ActiveRecordCompose
6
7
  using ComposedCollection::PackagePrivate
7
8
 
9
+ # @private
8
10
  module Persistence
9
11
  extend ActiveSupport::Concern
10
12
  include ActiveRecordCompose::TransactionSupport
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordCompose
4
+ # @private
4
5
  module TransactionSupport
5
6
  extend ActiveSupport::Concern
6
7
  include ActiveRecord::Transactions
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "composed_collection"
4
+
3
5
  module ActiveRecordCompose
4
6
  using ComposedCollection::PackagePrivate
5
7
 
8
+ # @private
6
9
  module Validations
7
10
  extend ActiveSupport::Concern
8
11
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordCompose
4
- VERSION = "0.11.2"
4
+ VERSION = "0.11.3"
5
5
  end
@@ -3,10 +3,12 @@
3
3
  require "active_support/core_ext/object"
4
4
 
5
5
  module ActiveRecordCompose
6
+ # @private
6
7
  class WrappedModel
7
8
  # @param model [Object] the model instance.
8
- # @param destroy [Boolean] given true, destroy model.
9
- # @param destroy [Proc] when proc returning true, destroy model.
9
+ # @param destroy [Boolean, Proc, Symbol] Controls whether the model should be destroyed.
10
+ # - Boolean: if `true`, the model will be destroyed.
11
+ # - Proc: the model will be destroyed if the proc returns `true`.
10
12
  # @param if [Proc] evaluation result is false, it will not be included in the renewal.
11
13
  def initialize(model, destroy: false, if: nil)
12
14
  @model = model
@@ -5,5 +5,9 @@ require "active_record"
5
5
  require_relative "active_record_compose/version"
6
6
  require_relative "active_record_compose/model"
7
7
 
8
+ # namespaces in gem `active_record_compose`.
9
+ #
10
+ # Most of the functionality resides in {ActiveRecordCompose::Model}.
11
+ #
8
12
  module ActiveRecordCompose
9
13
  end
@@ -1,11 +1,46 @@
1
1
  module ActiveRecordCompose
2
- module AttributeQuerying
3
- include ActiveModel::AttributeMethods
2
+ module Attributes
4
3
  extend ActiveSupport::Concern
5
- extend ActiveModel::AttributeMethods::ClassMethods
4
+ include ActiveModel::Attributes
5
+ include Querying
6
6
 
7
- private
8
- def attribute?: (attribute_name) -> untyped
7
+ def delegated_attributes: () -> Array[Delegation]
8
+
9
+ module ClassMethods : Module
10
+ include ActiveModel::Attributes::ClassMethods
11
+ include ActiveModel::AttributeMethods::ClassMethods
12
+
13
+ def delegate_attribute: (*untyped methods, to: untyped, ?allow_nil: bool) -> untyped
14
+ def delegated_attributes: () -> Array[Delegation]
15
+ def delegated_attributes=: (Array[Delegation]) -> untyped
16
+ end
17
+
18
+ class Delegation
19
+ def initialize: (attribute: String, to: Symbol, ?allow_nil: bool) -> void
20
+ def attribute: () -> Symbol
21
+ def attribute_name: () -> String
22
+ def attribute_hash: (Object model) -> Hash[String, untyped]
23
+ def define_delegated_attribute: ((Module & ActiveModel::AttributeMethods::ClassMethods) klass) -> void
24
+
25
+ @attribute: Symbol
26
+ @to: Symbol
27
+ @allow_nil: bool
28
+
29
+ private
30
+ def to: () -> Symbol
31
+ def allow_nil: () -> bool
32
+ def reader: () -> String
33
+ def writer: () -> String
34
+ end
35
+
36
+ module Querying
37
+ include ActiveModel::AttributeMethods
38
+ extend ActiveSupport::Concern
39
+ extend ActiveModel::AttributeMethods::ClassMethods
40
+
41
+ private
42
+ def attribute?: (attribute_name) -> untyped
43
+ end
9
44
  end
10
45
 
11
46
  module Callbacks
@@ -37,34 +72,9 @@ module ActiveRecordCompose
37
72
  include PackagePrivate
38
73
  end
39
74
 
40
- module DelegateAttribute
41
- include ActiveModel::Attributes
42
- extend ActiveSupport::Concern
43
-
44
- class Delegation
45
- def initialize: (attribute: String, to: Symbol, allow_nil: boolish) -> void
46
- def attribute: () -> String
47
- def to: () -> Symbol
48
- def allow_nil: () -> boolish
49
- def reader: () -> String
50
- def writer: () -> String
51
- end
52
-
53
- def delegated_attributes: () -> Array[Delegation]
54
-
55
- module ClassMethods : Module
56
- include ActiveModel::Attributes::ClassMethods
57
- include ActiveModel::AttributeMethods::ClassMethods
58
-
59
- def delegate_attribute: (*untyped methods, to: untyped, ?allow_nil: untyped?) -> untyped
60
- def delegated_attributes: () -> Array[Delegation]
61
- def delegated_attributes=: (Array[Delegation]) -> untyped
62
- end
63
- end
64
-
65
75
  class Model
66
- include DelegateAttribute
67
- extend DelegateAttribute::ClassMethods
76
+ include Attributes
77
+ extend Attributes::ClassMethods
68
78
  include TransactionSupport
69
79
  extend TransactionSupport::ClassMethods
70
80
  include Callbacks
@@ -71,7 +71,7 @@ module ActiveRecordCompose
71
71
  def self.after_commit: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
72
72
  def self.after_rollback: (*callback[instance], ?if: condition[instance], ?unless: condition[instance], **untyped) ?{ () [self: instance] -> void } -> void
73
73
 
74
- def self.delegate_attribute: (*untyped methods, to: untyped, ?allow_nil: untyped?) -> untyped
74
+ def self.delegate_attribute: (*untyped methods, to: untyped, ?allow_nil: bool) -> untyped
75
75
  def self.connection: -> ActiveRecord::ConnectionAdapters::AbstractAdapter
76
76
  def self.lease_connection: -> ActiveRecord::ConnectionAdapters::AbstractAdapter
77
77
  def self.with_connection: [T] () { () -> T } -> T
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_record_compose
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.2
4
+ version: 0.11.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamajyotan
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-06-29 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -38,15 +38,17 @@ extensions: []
38
38
  extra_rdoc_files: []
39
39
  files:
40
40
  - ".rubocop.yml"
41
+ - ".yardopts"
41
42
  - CHANGELOG.md
42
43
  - CODE_OF_CONDUCT.md
43
44
  - LICENSE.txt
44
45
  - README.md
45
46
  - lib/active_record_compose.rb
46
- - lib/active_record_compose/attribute_querying.rb
47
+ - lib/active_record_compose/attributes.rb
48
+ - lib/active_record_compose/attributes/delegation.rb
49
+ - lib/active_record_compose/attributes/querying.rb
47
50
  - lib/active_record_compose/callbacks.rb
48
51
  - lib/active_record_compose/composed_collection.rb
49
- - lib/active_record_compose/delegate_attribute.rb
50
52
  - lib/active_record_compose/model.rb
51
53
  - lib/active_record_compose/persistence.rb
52
54
  - lib/active_record_compose/transaction_support.rb
@@ -62,7 +64,7 @@ metadata:
62
64
  homepage_uri: https://github.com/hamajyotan/active_record_compose
63
65
  source_code_uri: https://github.com/hamajyotan/active_record_compose
64
66
  changelog_uri: https://github.com/hamajyotan/active_record_compose/blob/main/CHANGELOG.md
65
- documentation_uri: https://www.rubydoc.info/gems/active_record_compose/0.11.2
67
+ documentation_uri: https://hamajyotan.github.io/active_record_compose/
66
68
  rubygems_mfa_required: 'true'
67
69
  rdoc_options: []
68
70
  require_paths:
@@ -78,7 +80,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
78
80
  - !ruby/object:Gem::Version
79
81
  version: '0'
80
82
  requirements: []
81
- rubygems_version: 3.6.2
83
+ rubygems_version: 3.6.7
82
84
  specification_version: 4
83
85
  summary: activemodel form object pattern
84
86
  test_files: []
@@ -1,67 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveRecordCompose
4
- # = Attribute \Querying
5
- #
6
- # This provides predicate methods based on the attributes.
7
- #
8
- # class AccountRegistration < ActiveRecordCompose::Model
9
- # def initialize
10
- # @account = Account.new
11
- # super()
12
- # models << account
13
- # end
14
- #
15
- # attribute :original_attr
16
- # delegate_attribute :name, :email, to: :account
17
- #
18
- # private
19
- #
20
- # attr_reader :account
21
- # end
22
- #
23
- # model = AccountRegistration.new
24
- #
25
- # model.name #=> nil
26
- # model.name? #=> false
27
- # model.name = 'Alice'
28
- # model.name? #=> true
29
- #
30
- # model.original_attr = "Bob"
31
- # model.original_attr? #=> true
32
- # model.original_attr = ""
33
- # model.original_attr? #=> false
34
- #
35
- # # If the value is numeric, it returns the result of checking whether it is zero or not.
36
- # # This behavior is consistent with `ActiveRecord::AttributeMethods::Query`.
37
- # model.original_attr = 123
38
- # model.original_attr? #=> true
39
- # model.original_attr = 0
40
- # model.original_attr? #=> false
41
- #
42
- module AttributeQuerying
43
- extend ActiveSupport::Concern
44
- include ActiveModel::AttributeMethods
45
-
46
- included do
47
- attribute_method_suffix "?", parameters: false
48
- end
49
-
50
- private
51
-
52
- def attribute?(attr_name)
53
- value = public_send(attr_name)
54
-
55
- case value
56
- when true then true
57
- when false, nil then false
58
- else
59
- if value.respond_to?(:zero?)
60
- !value.zero?
61
- else
62
- value.present?
63
- end
64
- end
65
- end
66
- end
67
- end
@@ -1,95 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveRecordCompose
4
- # = Delegate \Attribute
5
- #
6
- # It provides a macro description that expresses access to the attributes of the AR model through delegation.
7
- #
8
- # class AccountRegistration < ActiveRecordCompose::Model
9
- # def initialize(account, attributes = {})
10
- # @account = account
11
- # super(attributes)
12
- # models.push(account)
13
- # end
14
- #
15
- # attribute :original_attribute, :string, default: 'qux'
16
- #
17
- # # like a `delegate :name, :name=, to: :account`
18
- # delegate_attribute :name, to: :account
19
- #
20
- # private
21
- #
22
- # attr_reader :account
23
- # end
24
- #
25
- # account = Account.new
26
- # account.name = 'foo'
27
- #
28
- # registration = AccountRegistration.new(account)
29
- # registration.name #=> 'foo' # delegate to account#name
30
- #
31
- # registration.name = 'bar' # delegate to account#name=
32
- # account.name #=> 'bar'
33
- #
34
- # # Attributes defined in delegate_attribute will be included in the original `#attributes`.
35
- # registration.attributes #=> { 'original_attribute' => 'qux', 'name' => 'bar' }
36
- #
37
- module DelegateAttribute
38
- extend ActiveSupport::Concern
39
- include ActiveModel::Attributes
40
-
41
- # steep:ignore:start
42
- if defined?(Data)
43
- Delegation = Data.define(:attribute, :to, :allow_nil) do
44
- def reader = attribute.to_s
45
- def writer = "#{attribute}="
46
- end
47
- else
48
- Delegation = Struct.new(:attribute, :to, :allow_nil, keyword_init: true) do
49
- def reader = attribute.to_s
50
- def writer = "#{attribute}="
51
- end
52
- end
53
- # steep:ignore:end
54
-
55
- included do
56
- # @type self: Class
57
- class_attribute :delegated_attributes, instance_writer: false
58
- end
59
-
60
- module ClassMethods
61
- # Defines the reader and writer for the specified attribute.
62
- #
63
- def delegate_attribute(*attributes, to:, allow_nil: nil)
64
- delegations = attributes.map { Delegation.new(attribute: _1.to_s, to:, allow_nil:) }
65
-
66
- delegations.map do |delegation|
67
- delegate delegation.reader, delegation.writer, to: delegation.to, allow_nil: delegation.allow_nil
68
- define_attribute_methods delegation.attribute
69
- end
70
-
71
- self.delegated_attributes = (delegated_attributes.to_a + delegations).reverse.uniq { _1.attribute }.reverse
72
- end
73
-
74
- # Returns a array of attribute name.
75
- # Attributes declared with `delegate_attribute` are also merged.
76
- #
77
- # @return [Array<String>] array of attribute name.
78
- def attribute_names = super + delegated_attributes.to_a.map { _1.attribute }
79
- end
80
-
81
- # Returns a array of attribute name.
82
- # Attributes declared with `delegate_attribute` are also merged.
83
- #
84
- # @return [Array<String>] array of attribute name.
85
- def attribute_names = super + delegated_attributes.to_a.map { _1.attribute }
86
-
87
- # Returns a hash with the attribute name as key and the attribute value as value.
88
- # Attributes declared with `delegate_attribute` are also merged.
89
- #
90
- # @return [Hash] hash with the attribute name as key and the attribute value as value.
91
- def attributes
92
- super.merge(delegated_attributes.to_a.map { _1.attribute }.to_h { [ _1, public_send(_1) ] })
93
- end
94
- end
95
- end