active_record_compose 0.9.0 → 0.10.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: dba7d13bf8f2dc9ef449ead00c1a7ae3fff83e216f07b5d11606659a3c1f4650
4
- data.tar.gz: 883a9480ff3e6bd4c99246c12200a7b5a6e44103c0d1c967ce97c4536aa478b1
3
+ metadata.gz: c78fcb982e3f8f36a5497483ae835eaba9724d72342efc0866ca596c25fa45a8
4
+ data.tar.gz: 515f08b1def287d06c1d8436a0c3cb466ef75016094e4778b0e9ba992c95cc30
5
5
  SHA512:
6
- metadata.gz: 905bf7969733f738e00726f0dc7ed1442bce1f598485a72a0478fd67069c556b5d8299fdc5485636b1194e095649df550ba9f3c01ffb42c107a8a69afcde85ae
7
- data.tar.gz: 22f44a63d515d31682f22641e112c3bf720c5b3ff29c8fb0a30ceb777b23847a74f053eb3591211c35bfe2e2f96de345523236d08b30365059fc3a7421437bf4
6
+ metadata.gz: 1976cef02cc6dbf8d9d543b339254136733c580f116f2ad38c1239fd7a18bdf9a2bde27d7a22be4f041c0eb2576f1bbc97d6e18cfd5859dae52fb2a90624ab69
7
+ data.tar.gz: 71130efd7792fa18dc33903d12bd6eb7ac17ac59bff95ceeedb2200ce128c3f34fdc7229866f8b5fbeea68f1136bb84dd35a17e2e0167b5990d2b26723022ba3
data/.rubocop.yml CHANGED
@@ -24,6 +24,9 @@ Style/NumericPredicate:
24
24
  Style/OptionalBooleanParameter:
25
25
  Enabled: false
26
26
 
27
+ Style/ParallelAssignment:
28
+ Enabled: false
29
+
27
30
  Style/StringLiterals:
28
31
  Enabled: true
29
32
 
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.10.0] - 2025-04-07
4
+
5
+ - avoid twice validation. As a side effect, save must accept argument `#save(**options)`.
6
+ In line with this, the model to be put into models must be
7
+ at least responsive to `model.valid? && model.save(validate: false)`, not `model.save()` (no arguments).
8
+ - supports context as the first argument of `#valid?`, for example `model.valid(:custom_context)`.
9
+ At the same time, it accepts `options[:context]` in `#save(**options)`, such as `model.save(context: :custom_context)`.
10
+ However, this is a convenience support to unify the interface, not a positive one.
11
+
3
12
  ## [0.9.0] - 2025-03-16
4
13
 
5
14
  - removed `persisted_flag_callback_control` support.
data/README.md CHANGED
@@ -16,6 +16,7 @@ activemodel (activerecord) form object pattern. it embraces multiple AR models a
16
16
  - [Advanced Usage](#advanced-usage)
17
17
  - [`destroy` option](#destroy-option)
18
18
  - [Callback ordering by `#persisted?`](#callback-ordering-by-persisted)
19
+ - [`#save` with custom context option](#save-with-custom-context-option)
19
20
  - [Links](#links)
20
21
  - [Development](#development)
21
22
  - [Contributing](#contributing)
@@ -338,6 +339,61 @@ model.save # or `model.update` (the same callbacks will be triggered in all case
338
339
  # after_save called!
339
340
  ```
340
341
 
342
+ ### `#save` with custom context option
343
+
344
+ The interface remains consistent with standard ActiveModel and ActiveRecord models, so the :context option works with #save.
345
+
346
+ ```ruby
347
+ composed_model.valid?(:custom_context)
348
+
349
+ composed_model.save(context: :custom_context)
350
+ ```
351
+
352
+ However, this may not be ideal from a design perspective.
353
+ If your application requires complex context-specific validations, consider separating models by context.
354
+
355
+ ```ruby
356
+ class Account < ActiveRecord::Base
357
+ validates :name, presence: true
358
+ validates :email, presence: true
359
+ validates :email, format: { with: /\.edu\z/ }, on: :education
360
+ end
361
+
362
+ class Registration < ActiveRecordCompose::Model
363
+ def initialize(attributes = {})
364
+ models.push(@account = Account.new)
365
+ super(attributes)
366
+ end
367
+
368
+ attribute :accept, :boolean
369
+ validates :accept, presence: true, on: :education
370
+
371
+ delegate_attribute :name, :email, to: :account
372
+
373
+ private
374
+
375
+ attr_reader :account
376
+ end
377
+ ```
378
+ ```ruby
379
+ r = Registration.new(name: 'foo', email: 'example@example.com', accept: false)
380
+ r.valid?
381
+ => true
382
+
383
+ r.valid?(:education)
384
+ => false
385
+ r.errors.map { [_1.attribute, _1.type] }
386
+ => [[:email, :invalid], [:accept, :blank]]
387
+
388
+ r.email = 'example@example.edu'
389
+ r.accept = true
390
+
391
+ r.valid?(:education)
392
+ => true
393
+ r.save(context: :education)
394
+ => true
395
+ ```
396
+
341
397
  ## Links
342
398
 
343
399
  - [Smart way to update multiple models simultaneously in Rails](https://dev.to/hamajyotan/smart-way-to-update-multiple-models-simultaneously-in-rails-51b6)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordCompose
4
+ module Callbacks
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ define_model_callbacks :save
9
+ define_model_callbacks :create
10
+ define_model_callbacks :update
11
+ end
12
+
13
+ private
14
+
15
+ def with_callbacks(&block) = run_callbacks(:save) { run_callbacks(callback_context, &block) }
16
+
17
+ def callback_context = persisted? ? :update : :create
18
+ end
19
+ end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_record_compose/callbacks'
3
4
  require 'active_record_compose/composed_collection'
4
5
  require 'active_record_compose/delegate_attribute'
5
6
  require 'active_record_compose/transaction_support'
7
+ require 'active_record_compose/validations'
6
8
 
7
9
  module ActiveRecordCompose
8
10
  using ComposedCollection::PackagePrivate
@@ -12,12 +14,10 @@ module ActiveRecordCompose
12
14
  include ActiveModel::Validations::Callbacks
13
15
  include ActiveModel::Attributes
14
16
 
17
+ include ActiveRecordCompose::Callbacks
15
18
  include ActiveRecordCompose::DelegateAttribute
16
19
  include ActiveRecordCompose::TransactionSupport
17
-
18
- define_model_callbacks :save
19
- define_model_callbacks :create
20
- define_model_callbacks :update
20
+ prepend ActiveRecordCompose::Validations
21
21
 
22
22
  validate :validate_models
23
23
 
@@ -30,16 +30,16 @@ module ActiveRecordCompose
30
30
  #
31
31
  # The save is performed within a single transaction.
32
32
  #
33
- # Options like `:validate` and `:context` are not accepted as arguments.
34
- # The need for such values indicates that operations from multiple contexts are being handled.
35
- # However, if the contexts are different, it is recommended to separate them into different model definitions.
33
+ # Only the `:validate` option takes effect as it is required internally.
34
+ # However, we do not recommend explicitly specifying `validate: false` to skip validation.
35
+ # Additionally, the `:context` option is not accepted.
36
+ # The need for such a value indicates that operations from multiple contexts are being processed.
37
+ # If the contexts differ, we recommend separating them into different model definitions.
36
38
  #
37
39
  # @return [Boolean] returns true on success, false on failure.
38
- def save
39
- return false if invalid?
40
-
40
+ def save(**options)
41
41
  with_transaction_returning_status do
42
- with_callbacks { save_models(bang: false) }
42
+ with_callbacks { save_models(**options, bang: false) }
43
43
  rescue ActiveRecord::RecordInvalid
44
44
  false
45
45
  end
@@ -50,15 +50,15 @@ module ActiveRecordCompose
50
50
  #
51
51
  # Saving, like `#save`, is performed within a single transaction.
52
52
  #
53
- # Options like `:validate` and `:context` are not accepted as arguments.
54
- # The need for such values indicates that operations from multiple contexts are being handled.
55
- # However, if the contexts are different, it is recommended to separate them into different model definitions.
53
+ # Only the `:validate` option takes effect as it is required internally.
54
+ # However, we do not recommend explicitly specifying `validate: false` to skip validation.
55
+ # Additionally, the `:context` option is not accepted.
56
+ # The need for such a value indicates that operations from multiple contexts are being processed.
57
+ # If the contexts differ, we recommend separating them into different model definitions.
56
58
  #
57
- def save!
58
- valid? || raise_validation_error
59
-
59
+ def save!(**options)
60
60
  with_transaction_returning_status do
61
- with_callbacks { save_models(bang: true) }
61
+ with_callbacks { save_models(**options, bang: true) }
62
62
  end || raise_on_save_error
63
63
  end
64
64
 
@@ -66,15 +66,19 @@ module ActiveRecordCompose
66
66
  #
67
67
  # @return [Boolean] returns true on success, false on failure.
68
68
  def update(attributes = {})
69
- assign_attributes(attributes)
70
- save
69
+ with_transaction_returning_status do
70
+ assign_attributes(attributes)
71
+ save
72
+ end
71
73
  end
72
74
 
73
75
  # Behavior is same to `#update`, but raises an exception prematurely on failure.
74
76
  #
75
77
  def update!(attributes = {})
76
- assign_attributes(attributes)
77
- save!
78
+ with_transaction_returning_status do
79
+ assign_attributes(attributes)
80
+ save!
81
+ end
78
82
  end
79
83
 
80
84
  # Returns true if model is persisted.
@@ -90,20 +94,10 @@ module ActiveRecordCompose
90
94
 
91
95
  def models = @__models ||= ActiveRecordCompose::ComposedCollection.new(self)
92
96
 
93
- def validate_models
94
- models.__wrapped_models.lazy.select { _1.invalid? }.each { errors.merge!(_1) }
95
- end
96
-
97
- def with_callbacks(&block) = run_callbacks(:save) { run_callbacks(callback_context, &block) }
98
-
99
- def callback_context = persisted? ? :update : :create
100
-
101
- def save_models(bang:)
102
- models.__wrapped_models.all? { bang ? _1.save! : _1.save }
97
+ def save_models(bang:, **options)
98
+ models.__wrapped_models.all? { bang ? _1.save!(**options, validate: false) : _1.save(**options, validate: false) }
103
99
  end
104
100
 
105
- def raise_validation_error = raise ActiveRecord::RecordInvalid, self
106
-
107
101
  def raise_on_save_error = raise ActiveRecord::RecordNotSaved.new(raise_on_save_error_message, self)
108
102
 
109
103
  def raise_on_save_error_message = 'Failed to save the model.'
@@ -18,7 +18,7 @@ module ActiveRecordCompose
18
18
 
19
19
  def with_connection(&) = ar_class.with_connection(&) # steep:ignore
20
20
 
21
- def composite_primary_key? = false
21
+ def composite_primary_key? = false # steep:ignore
22
22
 
23
23
  private
24
24
 
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordCompose
4
+ using ComposedCollection::PackagePrivate
5
+
6
+ module Validations
7
+ def save(**options)
8
+ perform_validations(options) ? super : false
9
+ end
10
+
11
+ def save!(**options)
12
+ perform_validations(options) ? super : raise_validation_error
13
+ end
14
+
15
+ def valid?(context = nil) = context_for_override_validation.with_override(context) { super }
16
+
17
+ private
18
+
19
+ def validate_models
20
+ context = override_validation_context
21
+ models.__wrapped_models.lazy.select { _1.invalid?(context) }.each { errors.merge!(_1) }
22
+ end
23
+
24
+ def perform_validations(options)
25
+ options[:validate] == false || valid?(options[:context])
26
+ end
27
+
28
+ def raise_validation_error = raise ActiveRecord::RecordInvalid, self
29
+
30
+ def context_for_override_validation
31
+ @context_for_override_validation ||= OverrideValidationContext.new
32
+ end
33
+
34
+ def override_validation_context = context_for_override_validation.context
35
+
36
+ class OverrideValidationContext
37
+ attr_reader :context
38
+
39
+ def with_override(context)
40
+ @context, original = context, @context
41
+ yield
42
+ ensure
43
+ @context = original # steep:ignore
44
+ end
45
+ end
46
+ private_constant :OverrideValidationContext
47
+ end
48
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordCompose
4
- VERSION = '0.9.0'
4
+ VERSION = '0.10.0'
5
5
  end
@@ -60,7 +60,7 @@ module ActiveRecordCompose
60
60
  # Whether save or destroy is executed depends on the value of `#destroy_context?`.
61
61
  #
62
62
  # @return [Boolean] returns true on success, false on failure.
63
- def save
63
+ def save(**options)
64
64
  # While errors caused by the type check are avoided,
65
65
  # it is important to note that an error can still occur
66
66
  # if `#destroy_context?` returns true but ar_like does not implement `#destroy`.
@@ -70,14 +70,14 @@ module ActiveRecordCompose
70
70
  m.destroy
71
71
  else
72
72
  # @type var m: ActiveRecordCompose::_ARLike
73
- m.save
73
+ m.save(**options)
74
74
  end
75
75
  end
76
76
 
77
77
  # Execute save or destroy. Unlike #save, an exception is raises on failure.
78
78
  # Whether save or destroy is executed depends on the value of `#destroy_context?`.
79
79
  #
80
- def save!
80
+ def save!(**options)
81
81
  # While errors caused by the type check are avoided,
82
82
  # it is important to note that an error can still occur
83
83
  # if `#destroy_context?` returns true but ar_like does not implement `#destroy`.
@@ -87,15 +87,15 @@ module ActiveRecordCompose
87
87
  m.destroy!
88
88
  else
89
89
  # @type var model: ActiveRecordCompose::_ARLike
90
- m.save!
90
+ m.save!(**options)
91
91
  end
92
92
  end
93
93
 
94
94
  # @return [Boolean]
95
- def invalid? = destroy_context? ? false : model.invalid?
95
+ def invalid?(context = nil) = !valid?(context)
96
96
 
97
97
  # @return [Boolean]
98
- def valid? = !invalid?
98
+ def valid?(context = nil) = destroy_context? || model.valid?(context)
99
99
 
100
100
  # Returns true if equivalent.
101
101
  #
@@ -1,4 +1,15 @@
1
1
  module ActiveRecordCompose
2
+ module Callbacks
3
+ include ActiveModel::Model
4
+ include ActiveModel::Validations::Callbacks
5
+ extend ActiveSupport::Concern
6
+ extend ActiveModel::Callbacks
7
+
8
+ private
9
+ def with_callbacks: { () -> bool } -> bool
10
+ def callback_context: -> (:create | :update)
11
+ end
12
+
2
13
  class ComposedCollection
3
14
  def initialize: (Model) -> void
4
15
 
@@ -35,8 +46,13 @@ module ActiveRecordCompose
35
46
  extend DelegateAttribute::ClassMethods
36
47
  include TransactionSupport
37
48
  extend TransactionSupport::ClassMethods
49
+ include ActiveRecordCompose::Callbacks
38
50
 
39
51
  @__models: ComposedCollection
52
+
53
+ private
54
+ def validate_models: -> void
55
+ def override_validation_context: -> validation_context
40
56
  end
41
57
 
42
58
  module TransactionSupport
@@ -54,14 +70,37 @@ module ActiveRecordCompose
54
70
  end
55
71
  end
56
72
 
73
+ module Validations : Model
74
+ def save: (**untyped options) -> bool
75
+ def save!: (**untyped options) -> untyped
76
+ def valid?: (?validation_context context) -> bool
77
+
78
+ @context_for_override_validation: OverrideValidationContext
79
+
80
+ private
81
+
82
+ def perform_validations: (::Hash[untyped, untyped]) -> bool
83
+ def raise_validation_error: -> bot
84
+ def context_for_override_validation: -> OverrideValidationContext
85
+ def override_validation_context: -> validation_context
86
+
87
+ class OverrideValidationContext
88
+ @context: validation_context
89
+
90
+ attr_reader context: validation_context
91
+
92
+ def with_override: [T] (validation_context) { () -> T } -> T
93
+ end
94
+ end
95
+
57
96
  class WrappedModel
58
97
  def initialize: (ar_like, ?destroy: (bool | destroy_context_type), ?if: (nil | condition_type)) -> void
59
98
  def destroy_context?: -> bool
60
99
  def ignore?: -> bool
61
- def save: -> bool
62
- def save!: -> untyped
63
- def invalid?: -> bool
64
- def valid?: -> bool
100
+ def save: (**untyped options) -> bool
101
+ def save!: (**untyped options) -> untyped
102
+ def invalid?: (?validation_context context) -> bool
103
+ def valid?: (?validation_context context) -> bool
65
104
  def is_a?: (untyped) -> bool
66
105
  def ==: (untyped) -> bool
67
106
 
@@ -5,27 +5,29 @@ module ActiveRecordCompose
5
5
  VERSION: String
6
6
 
7
7
  interface _ARLike
8
- def save: -> bool
9
- def save!: -> untyped
10
- def invalid?: -> bool
11
- def valid?: -> bool
8
+ def save: (**untyped options) -> bool
9
+ def save!: (**untyped options) -> untyped
10
+ def invalid?: (?validation_context context) -> bool
11
+ def valid?: (?validation_context context) -> bool
12
12
  def errors: -> untyped
13
13
  def is_a?: (untyped) -> bool
14
14
  def ==: (untyped) -> bool
15
15
  end
16
16
  interface _ARLikeWithDestroy
17
- def save: -> bool
18
- def save!: -> untyped
17
+ def save: (**untyped options) -> bool
18
+ def save!: (**untyped options) -> untyped
19
19
  def destroy: -> bool
20
20
  def destroy!: -> untyped
21
- def invalid?: -> bool
22
- def valid?: -> bool
21
+ def invalid?: (?validation_context context) -> bool
22
+ def valid?: (?validation_context context) -> bool
23
23
  def errors: -> untyped
24
24
  def is_a?: (untyped) -> bool
25
25
  def ==: (untyped) -> bool
26
26
  end
27
27
  type ar_like = (_ARLike | _ARLikeWithDestroy)
28
28
 
29
+ type validation_context = nil | Symbol | Array[Symbol]
30
+
29
31
  type condition[T] = Symbol | ^(T) [self: T] -> boolish
30
32
  type callback[T] = Symbol | ^(T) [self: T] -> void
31
33
  type around_callback[T] = Symbol | ^(T, Proc) [self: T] -> void
@@ -75,21 +77,15 @@ module ActiveRecordCompose
75
77
  def self.with_connection: [T] () { () -> T } -> T
76
78
 
77
79
  def initialize: (?Hash[attribute_name, untyped]) -> void
78
- def save: -> bool
79
- def save!: -> untyped
80
- def create: (?Hash[attribute_name, untyped]) -> bool
81
- def create!: (?Hash[attribute_name, untyped]) -> untyped
80
+ def save: (**untyped options) -> bool
81
+ def save!: (**untyped options) -> untyped
82
82
  def update: (?Hash[attribute_name, untyped]) -> bool
83
83
  def update!: (?Hash[attribute_name, untyped]) -> untyped
84
84
  def id: -> untyped
85
85
 
86
86
  private
87
87
  def models: -> ComposedCollection
88
- def with_callbacks: { () -> bool } -> bool
89
- def callback_context: -> (:create | :update)
90
- def validate_models: -> void
91
- def save_models: (bang: bool) -> bool
92
- def raise_validation_error: -> bot
88
+ def save_models: (bang: bool, **untyped options) -> bool
93
89
  def raise_on_save_error: -> bot
94
90
  def raise_on_save_error_message: -> String
95
91
  end
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.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamajyotan
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-03-16 00:00:00.000000000 Z
10
+ date: 2025-04-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -37,10 +37,12 @@ files:
37
37
  - LICENSE.txt
38
38
  - README.md
39
39
  - lib/active_record_compose.rb
40
+ - lib/active_record_compose/callbacks.rb
40
41
  - lib/active_record_compose/composed_collection.rb
41
42
  - lib/active_record_compose/delegate_attribute.rb
42
43
  - lib/active_record_compose/model.rb
43
44
  - lib/active_record_compose/transaction_support.rb
45
+ - lib/active_record_compose/validations.rb
44
46
  - lib/active_record_compose/version.rb
45
47
  - lib/active_record_compose/wrapped_model.rb
46
48
  - sig/_internal/package_private.rbs
@@ -52,7 +54,7 @@ metadata:
52
54
  homepage_uri: https://github.com/hamajyotan/active_record_compose
53
55
  source_code_uri: https://github.com/hamajyotan/active_record_compose
54
56
  changelog_uri: https://github.com/hamajyotan/active_record_compose/blob/main/CHANGELOG.md
55
- documentation_uri: https://www.rubydoc.info/gems/active_record_compose/0.9.0
57
+ documentation_uri: https://www.rubydoc.info/gems/active_record_compose/0.10.0
56
58
  rubygems_mfa_required: 'true'
57
59
  rdoc_options: []
58
60
  require_paths: