active_record_compose 0.11.2 → 0.12.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 +4 -4
- data/.yardopts +4 -0
- data/CHANGELOG.md +29 -0
- data/README.md +6 -5
- data/lib/active_record_compose/attributes/attribute_predicate.rb +29 -0
- data/lib/active_record_compose/attributes/delegation.rb +46 -0
- data/lib/active_record_compose/attributes/querying.rb +62 -0
- data/lib/active_record_compose/attributes.rb +153 -0
- data/lib/active_record_compose/callbacks.rb +22 -0
- data/lib/active_record_compose/composed_collection.rb +16 -9
- data/lib/active_record_compose/model.rb +355 -17
- data/lib/active_record_compose/persistence.rb +13 -19
- data/lib/active_record_compose/transaction_support.rb +12 -10
- data/lib/active_record_compose/validations.rb +4 -0
- data/lib/active_record_compose/version.rb +1 -1
- data/lib/active_record_compose/wrapped_model.rb +4 -2
- data/lib/active_record_compose.rb +4 -0
- data/sig/_internal/package_private.rbs +56 -34
- data/sig/active_record_compose.rbs +3 -7
- metadata +9 -6
- data/lib/active_record_compose/attribute_querying.rb +0 -67
- data/lib/active_record_compose/delegate_attribute.rb +0 -95
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 954ce34124a1c8e83a45710e104dd965c407afb5ba296b74d4a4e5f9d13d971a
|
4
|
+
data.tar.gz: c1bb35ef5458a804f243b886c037aff3794822477ea2aaf5a57bfb12a4001477
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2e7f81f304cec10420cb4f405ad8ad7f93f3811c7f43bf9952c05dbca84e651bbf0c851e648349d7e93986cf67cb177110785588a47c8951718f4d271cf53944
|
7
|
+
data.tar.gz: 8ac490bd69c0d51e6e89f1f198a4e052eccbeedcf1b234edfa8c9635819fe336bbe10b6b64b6b55907524543ad34eb9343892b00f54f2737a65c45c120456afa
|
data/.yardopts
ADDED
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,34 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.12.0] - 2025-08-21
|
4
|
+
|
5
|
+
- Omits default arguments for `#update` and `#update!`. It's to align I/F with ActiveRecord.
|
6
|
+
(https://github.com/hamajyotan/active_record_compose/pull/25)
|
7
|
+
- `#update(attributes = {})` to `#update(attributes)`
|
8
|
+
- `#update!(attributes = {})` to `#update!(attributes)`
|
9
|
+
- Omitted Specify instance variables in the `:to` option of `delegate_attribute`.
|
10
|
+
(https://github.com/hamajyotan/active_record_compose/pull/29)
|
11
|
+
- Omitted `#destroy` and `#touch` from `ActiveRecordCompose::Model`.
|
12
|
+
These were unintentionally provided by the `ActiveRecord::Transactions` module. The but in fact did not work correctly.
|
13
|
+
(https://github.com/hamajyotan/active_record_compose/pull/27)
|
14
|
+
|
15
|
+
## [0.11.3] - 2025-07-13
|
16
|
+
|
17
|
+
- refactor: Aggregation attribute module.
|
18
|
+
(https://github.com/hamajyotan/active_record_compose/pull/24)
|
19
|
+
- Warn against specifying instance variables, etc. directly in the `:to` option of `delegate_attribute`.
|
20
|
+
- Deprecated:
|
21
|
+
```ruby
|
22
|
+
delegate_attribute :foo, to: :@model
|
23
|
+
```
|
24
|
+
- Recommended:
|
25
|
+
```ruby
|
26
|
+
delegate_attribute :foo, to: :model
|
27
|
+
private
|
28
|
+
attr_reader :model
|
29
|
+
```
|
30
|
+
- doc: Expansion of yard documentation comments.
|
31
|
+
|
3
32
|
## [0.11.2] - 2025-06-29
|
4
33
|
|
5
34
|
- `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
|
-
|
384
|
+
#=> true
|
385
385
|
|
386
386
|
r.valid?(:education)
|
387
|
-
|
387
|
+
#=> false
|
388
388
|
r.errors.map { [_1.attribute, _1.type] }
|
389
|
-
|
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
|
-
|
395
|
+
#=> true
|
396
396
|
r.save(context: :education)
|
397
|
-
|
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,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecordCompose
|
4
|
+
module Attributes
|
5
|
+
# @private
|
6
|
+
class AttributePredicate
|
7
|
+
def initialize(value)
|
8
|
+
@value = value
|
9
|
+
end
|
10
|
+
|
11
|
+
def call
|
12
|
+
case value
|
13
|
+
when true then true
|
14
|
+
when false, nil then false
|
15
|
+
else
|
16
|
+
if value.respond_to?(:zero?)
|
17
|
+
!value.zero?
|
18
|
+
else
|
19
|
+
value.present?
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
attr_reader :value
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "attribute_predicate"
|
4
|
+
|
5
|
+
module ActiveRecordCompose
|
6
|
+
module Attributes
|
7
|
+
# @private
|
8
|
+
class Delegation
|
9
|
+
# @return [Symbol] The attribute name as symbol
|
10
|
+
attr_reader :attribute
|
11
|
+
|
12
|
+
def initialize(attribute:, to:, allow_nil: false)
|
13
|
+
@attribute = attribute.to_sym
|
14
|
+
@to = to.to_sym
|
15
|
+
@allow_nil = !!allow_nil
|
16
|
+
|
17
|
+
freeze
|
18
|
+
end
|
19
|
+
|
20
|
+
def define_delegated_attribute(klass)
|
21
|
+
klass.delegate(reader, writer, to:, allow_nil:)
|
22
|
+
klass.module_eval <<~RUBY, __FILE__, __LINE__ + 1
|
23
|
+
def #{reader}?
|
24
|
+
ActiveRecordCompose::Attributes::AttributePredicate.new(#{reader}).call
|
25
|
+
end
|
26
|
+
RUBY
|
27
|
+
end
|
28
|
+
|
29
|
+
# @return [String] The attribute name as string
|
30
|
+
def attribute_name = attribute.to_s
|
31
|
+
|
32
|
+
# @return [Hash<String, Object>]
|
33
|
+
def attribute_hash(model)
|
34
|
+
{ attribute_name => model.public_send(attribute) }
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
attr_reader :to, :allow_nil
|
40
|
+
|
41
|
+
def reader = attribute.to_s
|
42
|
+
|
43
|
+
def writer = "#{attribute}="
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "attribute_predicate"
|
4
|
+
|
5
|
+
module ActiveRecordCompose
|
6
|
+
module Attributes
|
7
|
+
# @private
|
8
|
+
# This provides predicate methods based on the attributes.
|
9
|
+
#
|
10
|
+
# @example
|
11
|
+
# class AccountRegistration < ActiveRecordCompose::Model
|
12
|
+
# def initialize
|
13
|
+
# @account = Account.new
|
14
|
+
# super()
|
15
|
+
# models << account
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# attribute :original_attr
|
19
|
+
# delegate_attribute :name, :email, to: :account
|
20
|
+
#
|
21
|
+
# private
|
22
|
+
#
|
23
|
+
# attr_reader :account
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# model = AccountRegistration.new
|
27
|
+
#
|
28
|
+
# model.name #=> nil
|
29
|
+
# model.name? #=> false
|
30
|
+
# model.name = "Alice"
|
31
|
+
# model.name? #=> true
|
32
|
+
#
|
33
|
+
# model.original_attr = "Bob"
|
34
|
+
# model.original_attr? #=> true
|
35
|
+
# model.original_attr = ""
|
36
|
+
# model.original_attr? #=> false
|
37
|
+
#
|
38
|
+
# # If the value is numeric, it returns the result of checking whether it is zero or not.
|
39
|
+
# # This behavior is consistent with `ActiveRecord::AttributeMethods::Query`.
|
40
|
+
# model.original_attr = 123
|
41
|
+
# model.original_attr? #=> true
|
42
|
+
# model.original_attr = 0
|
43
|
+
# model.original_attr? #=> false
|
44
|
+
#
|
45
|
+
module Querying
|
46
|
+
extend ActiveSupport::Concern
|
47
|
+
include ActiveModel::AttributeMethods
|
48
|
+
|
49
|
+
included do
|
50
|
+
attribute_method_suffix "?", parameters: false
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def attribute?(attr_name) = query?(public_send(attr_name))
|
56
|
+
|
57
|
+
def query?(value)
|
58
|
+
ActiveRecordCompose::Attributes::AttributePredicate.new(value).call
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "attributes/attribute_predicate"
|
4
|
+
require_relative "attributes/delegation"
|
5
|
+
require_relative "attributes/querying"
|
6
|
+
|
7
|
+
module ActiveRecordCompose
|
8
|
+
# @private
|
9
|
+
#
|
10
|
+
# Provides attribute-related functionality for use within ActiveRecordCompose::Model.
|
11
|
+
#
|
12
|
+
# This module allows you to define attributes on your composed model, including support
|
13
|
+
# for query methods (e.g., `#attribute?`) and delegation of attributes to underlying
|
14
|
+
# ActiveRecord instances via macros.
|
15
|
+
#
|
16
|
+
# For example, `.delegate_attribute` defines attribute accessors that delegate to
|
17
|
+
# a specific model, similar to:
|
18
|
+
#
|
19
|
+
# delegate :name, :name=, to: :account
|
20
|
+
#
|
21
|
+
# Additionally, delegated attributes are included in the composed model's `#attributes`
|
22
|
+
# hash.
|
23
|
+
#
|
24
|
+
# @example
|
25
|
+
# class AccountRegistration < ActiveRecordCompose::Model
|
26
|
+
# def initialize(account, attributes = {})
|
27
|
+
# @account = account
|
28
|
+
# super(attributes)
|
29
|
+
# models.push(account)
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
# attribute :original_attribute, :string, default: "qux"
|
33
|
+
# delegate_attribute :name, to: :account
|
34
|
+
#
|
35
|
+
# private
|
36
|
+
#
|
37
|
+
# attr_reader :account
|
38
|
+
# end
|
39
|
+
#
|
40
|
+
# account = Account.new
|
41
|
+
# account.name = "foo"
|
42
|
+
#
|
43
|
+
# registration = AccountRegistration.new(account)
|
44
|
+
# registration.name # => "foo" (delegated)
|
45
|
+
# registration.name? # => true (delegated attribute method + `?`)
|
46
|
+
#
|
47
|
+
# registration.name = "bar" # => updates account.name
|
48
|
+
# account.name # => "bar"
|
49
|
+
# account.name? # => true
|
50
|
+
#
|
51
|
+
# registration.attributes
|
52
|
+
# # => { "original_attribute" => "qux", "name" => "bar" }
|
53
|
+
#
|
54
|
+
module Attributes
|
55
|
+
extend ActiveSupport::Concern
|
56
|
+
include ActiveModel::Attributes
|
57
|
+
|
58
|
+
included do
|
59
|
+
include Querying
|
60
|
+
|
61
|
+
# @type self: Class
|
62
|
+
class_attribute :delegated_attributes, instance_writer: false
|
63
|
+
end
|
64
|
+
|
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
|
94
|
+
#
|
95
|
+
# registration.attributes
|
96
|
+
# # => { "original_attribute" => "qux", "name" => "bar" }
|
97
|
+
#
|
98
|
+
def delegate_attribute(*attributes, to:, allow_nil: false)
|
99
|
+
if to.start_with?("@")
|
100
|
+
raise ArgumentError, "Instance variables cannot be specified in delegate to. (#{to})"
|
101
|
+
end
|
102
|
+
|
103
|
+
delegations = attributes.map { Delegation.new(attribute: _1, to:, allow_nil:) }
|
104
|
+
delegations.each { _1.define_delegated_attribute(self) }
|
105
|
+
|
106
|
+
self.delegated_attributes = (delegated_attributes.to_a + delegations).reverse.uniq { _1.attribute }.reverse
|
107
|
+
end
|
108
|
+
|
109
|
+
# Returns a array of attribute name.
|
110
|
+
# Attributes declared with `delegate_attribute` are also merged.
|
111
|
+
#
|
112
|
+
# @return [Array<String>] array of attribute name.
|
113
|
+
def attribute_names = super + delegated_attributes.to_a.map { _1.attribute_name }
|
114
|
+
end
|
115
|
+
|
116
|
+
# Returns a array of attribute name.
|
117
|
+
# Attributes declared with `delegate_attribute` are also merged.
|
118
|
+
#
|
119
|
+
# @return [Array<String>] array of attribute name.
|
120
|
+
def attribute_names = super + delegated_attributes.to_a.map { _1.attribute_name }
|
121
|
+
|
122
|
+
# Returns a hash with the attribute name as key and the attribute value as value.
|
123
|
+
# Attributes declared with `delegate_attribute` are also merged.
|
124
|
+
#
|
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
|
133
|
+
#
|
134
|
+
# attribute :original_attribute, :string, default: "qux"
|
135
|
+
# delegate_attribute :name, to: :account
|
136
|
+
#
|
137
|
+
# private
|
138
|
+
#
|
139
|
+
# attr_reader :account
|
140
|
+
# end
|
141
|
+
#
|
142
|
+
# account = Account.new
|
143
|
+
# account.name = "foo"
|
144
|
+
#
|
145
|
+
# registration = AccountRegistration.new(account)
|
146
|
+
#
|
147
|
+
# registration.attributes # => { "original_attribute" => "qux", "name" => "bar" }
|
148
|
+
#
|
149
|
+
def attributes
|
150
|
+
super.merge(*delegated_attributes.to_a.map { _1.attribute_hash(self) })
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -1,6 +1,21 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module ActiveRecordCompose
|
4
|
+
# @private
|
5
|
+
#
|
6
|
+
# Provides hooks into the life cycle of an ActiveRecordCompose model,
|
7
|
+
# allowing you to insert custom logic before or after changes to the object's state.
|
8
|
+
#
|
9
|
+
# The callback flow generally follows the same structure as Active Record:
|
10
|
+
#
|
11
|
+
# * `before_validation`
|
12
|
+
# * `after_validation`
|
13
|
+
# * `before_save`
|
14
|
+
# * `before_create` (or `before_update` for update operations)
|
15
|
+
# * `after_create` (or `after_update` for update operations)
|
16
|
+
# * `after_save`
|
17
|
+
# * `after_commit` (or `after_rollback` when the transaction is rolled back)
|
18
|
+
#
|
4
19
|
module Callbacks
|
5
20
|
extend ActiveSupport::Concern
|
6
21
|
include ActiveModel::Validations::Callbacks
|
@@ -13,8 +28,15 @@ module ActiveRecordCompose
|
|
13
28
|
|
14
29
|
private
|
15
30
|
|
31
|
+
# Evaluate while firing callbacks such as `before_save` `after_save`
|
32
|
+
# before and after block evaluation.
|
33
|
+
#
|
16
34
|
def with_callbacks(&block) = run_callbacks(:save) { run_callbacks(callback_context, &block) }
|
17
35
|
|
36
|
+
# Returns the symbol representing the callback context, which is `:create` if the record
|
37
|
+
# is new, or `:update` if it has been persisted.
|
38
|
+
#
|
39
|
+
# @return [:create, :update] either `:create` if not persisted, or `:update` if persisted
|
18
40
|
def callback_context = persisted? ? :update : :create
|
19
41
|
end
|
20
42
|
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]
|
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]
|
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]
|
40
|
-
# @param destroy [Boolean]
|
41
|
-
#
|
42
|
-
#
|
43
|
-
#
|
44
|
-
# @param if [Symbol]
|
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]
|
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
|