composable-core 0.0.13 → 0.0.14

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: 70e5155da7770ce04fad201ba581124b87f97ea3979d58f6d632667332c19128
4
- data.tar.gz: 71d9e06091dd8393992300ef63511e47ca0cd82ad8f31721b310088d4e502f63
3
+ metadata.gz: 6f4df88ab1a3ea8dfe4a54166f06d63608f480683efe0bc31beea1f2ca0b02e9
4
+ data.tar.gz: 307e555872f0f2a58ad7861cf9919643f66cf5d629ac45ba0667e935f02885af
5
5
  SHA512:
6
- metadata.gz: d49c2269c53bfc9547cb5a3a17962fc2db38ac490b377e75250f503f091e232efcb1aef030db020d1ed8f4f138b9b3986b5b768f07c5560c7f1278209db8882a
7
- data.tar.gz: 85397c8020abf54b51976494ccc9aef9ba71e8714b2618990243aa0845687b246627950afc71e2f1c1e1cc52a9621ffaef7355d435e7e4c46dd85c7d03fa2f27
6
+ metadata.gz: 265a79bd34dd978dd60005f4c474d43a42730270f4a4173e690739933526a02e601c9417254e9508752433aee321ba4363cd8c5c860f40c123deaf6f818b57ba
7
+ data.tar.gz: d769eb62f1232b101a9339f2a3951d8aa13f1b6fa54210d65ace8c0b92efb4011a5cd79cbddeea5c3db5ec34d90155b1f6cc1c5a63ff99276a3abaa8dd5cebf7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ - Evaluate composable eligibility once before synchronization and reuse it for persistence.
4
+ - Preserve reverse synchronization for predeclared record attributes.
5
+ - Copy constructor inputs and preserve explicit nil and symbol-key precedence.
6
+ - Deep-copy inheritance defaults and replace interpolated accessor generation.
7
+ - Preserve process-control exceptions and handle record validation without mutating errors, including late ActiveRecord loading.
8
+ - Replace placeholder tests and enforce complete line/branch coverage.
9
+
3
10
  ## [0.0.1] - 2022-01-20
4
11
 
5
12
  - Initial release
data/README.md CHANGED
@@ -1,43 +1,182 @@
1
- # Core
1
+ # composable-core
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/core`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Reusable concerns for attributes, command execution, callbacks, composed records,
4
+ and class configuration. Requires Ruby 3.2+, ActiveModel and ActiveSupport 7.2+.
5
+ Install with `gem "composable-core"` and require `composable/core`.
4
6
 
5
- TODO: Delete this and the text above, and describe your gem
7
+ ## Attributes
6
8
 
7
- ## Installation
9
+ ```ruby
10
+ require "composable/core"
11
+
12
+ class MessageInput
13
+ include Composable::Core::AttributeDSL
14
+
15
+ attribute :title, type: :string
16
+ attribute :count, type: :integer, default: 1
17
+ attribute :labels, default: []
18
+ attribute :requested_at, default: -> { Time.current }
19
+ end
20
+
21
+ input = {"title" => "Example", "count" => "2", class: "notice"}.freeze
22
+ message = MessageInput.new(input)
23
+ message.count # => 2
24
+ message.params[:title] # => "Example"
25
+ message.rest # => { class: "notice" }
26
+ ```
8
27
 
9
- Add this line to your application's Gemfile:
28
+ `attribute` declares readers, writers and `name?` predicates using Rails
29
+ `present?`. `attributes`/`attribute` without arguments return the declared Set;
30
+ `params` returns declared values; `rest` contains unknown keys. Unknown keys are
31
+ retained rather than assigned to methods.
32
+
33
+ Inputs must be hashes or hash-like objects exposing `each_pair` and `to_h`.
34
+ Permitted Rails parameters are accepted; unpermitted parameters raise through
35
+ Rails. Initialization copies the input, supports string and symbol keys and gives
36
+ the symbol key precedence. Explicit `nil` replaces a default. Mutable defaults
37
+ are deep-copied per instance; callable defaults run lazily without arguments.
38
+ Types are resolved through `ActiveModel::Type.lookup`, including registered
39
+ application types and their keyword options.
40
+
41
+ ## Commands and callbacks
42
+
43
+ Prefer the service/form/client bases when applicable. A custom command can
44
+ prepend `Composable::Core::Command` and implement `call`. The wrapper adds
45
+ ActiveModel validation and `rescue_from`, class-level `.call`/`.call!`, and
46
+ instance-level status/result methods. A custom implementation must call `valid?`
47
+ itself; the provided bases already do so. A missing implementation raises
48
+ `Composable::Core::Command::NotImplementedError`.
49
+
50
+ Handled exceptions return the command; handlers must record failure in `errors`.
51
+ `.call!` raises `Composable::Core::Error` when errors exist, and propagates raised
52
+ exceptions without invoking handlers. `Interrupt`, `SystemExit` and other
53
+ exceptions outside `StandardError` are never swallowed.
54
+
55
+ `Composable::Core::Callbacks` defines the `save` callback chain (`before_save`,
56
+ `around_save`, `after_save`). It does not execute that chain on its own.
57
+
58
+ ## Record composition
59
+
60
+ `ComposableDSL` pairs with `AttributeDSL`. Declare a record with
61
+ `composable(:project) { sync :name; sync :display_name, to: :name }`.
62
+ Assigning the record copies mapped values into the command, including when the
63
+ record attribute was declared before `composable`. Existing attribute type/default
64
+ configuration is retained. During initialization,
65
+ record values are copied first so explicit command attributes take precedence.
66
+ `save_composables` copies command values into records, calls `save!` on eligible
67
+ records, and executes an optional block.
68
+
69
+ Both a composable and an individual `sync` accept `if:` and `unless:` conditions.
70
+ Composable eligibility is evaluated once per save, before any record mappings
71
+ are applied. The same selected records are then synchronized and saved; a
72
+ condition depending on a record's old value is not re-evaluated after that value
73
+ changes. Individual `sync` conditions still run when their field is synchronized.
74
+ Use a method symbol or a Proc: zero-argument blocks run on the command; blocks
75
+ with an argument receive the record. Conditions use Ruby truthiness. Reverse
76
+ mapping on record assignment always initializes the command's values. Conditional
77
+ saving does not make a declared record optional: a missing record raises
78
+ `ArgumentError`.
79
+
80
+ Records exposing `transaction` are wrapped in nested transactions. Exceptions
81
+ roll back work on a shared connection. Separate databases do not provide a
82
+ distributed atomic commit; a commit failure can leave partial persistence.
83
+ Adding command errors alone does not roll back writes. With ActiveRecord loaded,
84
+ record validation errors are copied into command errors without changing the
85
+ record's original errors; record error names matching declared command attributes
86
+ are copied to those fields, while other names become base errors. Renamed sync
87
+ mappings do not rename validation-error fields automatically.
88
+
89
+ ## Inheritable configuration
90
+
91
+ Include `InheritableAttributes` and declare
92
+ `inheritable_attributes :settings, default: {}` for a deep copy of the parent's
93
+ current value in each subclass. `uninheritable_attributes :cache, default: []`
94
+ resets the subclass to a fresh copy of the declared default. False and nil values
95
+ are preserved. These are class settings, not request-local storage.
96
+
97
+
98
+ ## Build a custom command
99
+
100
+ Use the concerns directly when the service/form bases do not fit your lifecycle.
101
+ This standalone command validates before producing its result:
10
102
 
11
103
  ```ruby
12
- gem 'composable-core'
13
- ```
104
+ class FormatMessage
105
+ include Composable::Core::AttributeDSL
106
+ prepend Composable::Core::Command
107
+
108
+ attribute :text, type: :string
109
+ validates :text, presence: true
110
+
111
+ def call
112
+ return unless valid?
14
113
 
15
- And then execute:
114
+ text.strip.upcase
115
+ end
116
+ end
16
117
 
17
- $ bundle install
118
+ command = FormatMessage.call(text: " hello ")
119
+ command.result # => "HELLO"
120
+ command.success? # => true
18
121
 
19
- Or install it yourself as:
122
+ invalid = FormatMessage.call(text: "")
123
+ invalid.failure? # => true
124
+ invalid.errors.full_messages # => ["Text can't be blank"]
125
+ ```
20
126
 
21
- $ gem install composable-core
127
+ A module prepended to a base class wraps that base's implementation. A subclass
128
+ that overrides `call` can bypass the wrapper. For services/forms implement `save`,
129
+ and for HTTP clients implement `request`, to preserve the inherited lifecycle.
22
130
 
23
- ## Usage
131
+ ## Override defaults in a subclass
24
132
 
25
- TODO: Write usage instructions here
133
+ Attribute definitions are inherited independently; changing a subclass's default
134
+ does not change its parent. Mutable defaults are also independent per instance.
26
135
 
27
- ## Development
136
+ ```ruby
137
+ class BatchInput
138
+ include Composable::Core::AttributeDSL
139
+ attribute :limit, type: :integer, default: 10
140
+ attribute :labels, default: []
141
+ end
28
142
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
143
+ class SmallBatchInput < BatchInput
144
+ attribute :limit, default: 2
145
+ end
30
146
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
147
+ BatchInput.new.limit # => 10
148
+ SmallBatchInput.new.limit # => 2
32
149
 
33
- ## Contributing
150
+ first = BatchInput.new
151
+ second = BatchInput.new
152
+ first.labels << "priority"
153
+ second.labels # => []
34
154
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/jairovm/composables. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/jairovm/composables/tree/main/CODE_OF_CONDUCT.md).
155
+ BatchInput.new(limit: nil).limit # => nil, rather than the default
156
+ ```
36
157
 
37
- ## License
158
+ ## Copy or reset class configuration
38
159
 
39
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
160
+ ```ruby
161
+ class Processor
162
+ include Composable::Core::InheritableAttributes
163
+ inheritable_attributes :settings, default: { retries: 2, tags: [] }
164
+ uninheritable_attributes :cache, default: {}
165
+ end
166
+
167
+ Processor.settings[:tags] << "parent"
168
+ Processor.cache[:last_run] = "example"
169
+
170
+ class SpecializedProcessor < Processor
171
+ end
172
+
173
+ SpecializedProcessor.settings[:tags] # => ["parent"]
174
+ SpecializedProcessor.cache # => {}
175
+ SpecializedProcessor.settings[:tags] << "child"
176
+ Processor.settings[:tags] # => ["parent"]
177
+ ```
40
178
 
41
- ## Code of Conduct
179
+ Subclass configuration is copied when the subclass is created. Later parent
180
+ changes do not update existing subclasses automatically.
42
181
 
43
- Everyone interacting in the Core project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/jairovm/composables/tree/main/CODE_OF_CONDUCT.md).
182
+ See [testing](../docs/testing.md) for the isolated suite and coverage gates.
@@ -12,16 +12,15 @@ module Composable
12
12
  end
13
13
 
14
14
  def initialize(options = {})
15
- unless options.respond_to?(:has_key?)
16
- raise ArgumentError, "When assigning attributes, you must pass a hash" \
17
- " as an argument, #{options.class} passed."
18
- end
19
-
15
+ options = normalize_attributes(options)
20
16
  attributes.each do |attribute|
21
- # Try a `string` key if `symbol` key does not exist
22
- value = options.delete(attribute.to_sym)
23
- value = options.delete(attribute.to_s) if value.nil?
24
- send("#{attribute}=", value) unless value.nil?
17
+ if options.key?(attribute)
18
+ value = options.delete(attribute)
19
+ options.delete(attribute.to_s)
20
+ public_send("#{attribute}=", value)
21
+ elsif options.key?(attribute.to_s)
22
+ public_send("#{attribute}=", options.delete(attribute.to_s))
23
+ end
25
24
  end
26
25
 
27
26
  @rest = options
@@ -41,6 +40,17 @@ module Composable
41
40
  @rest
42
41
  end
43
42
 
43
+ private
44
+
45
+ def normalize_attributes(options)
46
+ unless options.respond_to?(:each_pair) && options.respond_to?(:to_h)
47
+ raise ArgumentError, "When assigning attributes, you must pass a hash"
48
+ end
49
+ options.to_h.dup
50
+ end
51
+
52
+ public
53
+
44
54
  module ClassMethods
45
55
  def attribute(*attrs)
46
56
  options = attrs.extract_options!
@@ -33,7 +33,7 @@ module Composable
33
33
  raise Error, errors.full_messages.to_sentence if raise_exception && failure?
34
34
 
35
35
  self
36
- rescue Exception => exception
36
+ rescue StandardError => exception
37
37
  raise if raise_exception || !rescue_with_handler(exception)
38
38
 
39
39
  self
@@ -15,9 +15,9 @@ module Composable
15
15
  composables[attribute.to_sym] ||= Composable.new(attribute)
16
16
  composables[attribute.to_sym].evaluate(**options, &block)
17
17
 
18
- return if attributes.include?(attribute.to_sym)
18
+ return if method_defined?("#{attribute}_original_setter=")
19
19
 
20
- attribute attribute
20
+ attribute(attribute) unless attributes.include?(attribute.to_sym)
21
21
 
22
22
  alias_method "#{attribute}_original_setter=", "#{attribute}="
23
23
 
@@ -34,9 +34,15 @@ module Composable
34
34
  # overridden by composable_record values, composable_record mapping methods
35
35
  # have to be synced first.
36
36
  def initialize(options = {})
37
+ options = normalize_attributes(options)
37
38
  composable_keys.each do |attribute|
38
- value = options.delete(attribute) || options.delete(attribute.to_s)
39
- send("#{attribute}=", value) if value
39
+ if options.key?(attribute)
40
+ value = options.delete(attribute)
41
+ options.delete(attribute.to_s)
42
+ public_send("#{attribute}=", value)
43
+ elsif options.key?(attribute.to_s)
44
+ public_send("#{attribute}=", options.delete(attribute.to_s))
45
+ end
40
46
  end
41
47
 
42
48
  super
@@ -48,8 +54,11 @@ module Composable
48
54
 
49
55
  def save_composables
50
56
  multi_db_transaction do
51
- sync_composable_records
52
- save_composable_records
57
+ eligible_composables = composables.select do |attribute, composable|
58
+ composable.valid?(self, composable_record_for(attribute))
59
+ end
60
+ sync_composable_records(eligible_composables)
61
+ save_composable_records(eligible_composables)
53
62
 
54
63
  yield if block_given?
55
64
  end
@@ -57,24 +66,22 @@ module Composable
57
66
 
58
67
  private
59
68
 
60
- def sync_composable_records
61
- composables.each do |attribute, composable|
69
+ def sync_composable_records(eligible_composables)
70
+ eligible_composables.each do |attribute, composable|
62
71
  record = composable_record_for(attribute)
63
- composable.sync_attributes(self, record) if composable.valid?(self, record)
72
+ composable.sync_attributes(self, record)
64
73
  end
65
74
  end
66
75
 
67
- def save_composable_records
68
- composables.each do |attribute, composable|
76
+ def save_composable_records(eligible_composables)
77
+ eligible_composables.each_key do |attribute|
69
78
  record = composable_record_for(attribute)
70
- record.save! if composable.valid?(self, record)
79
+ record.save!
71
80
  end
72
81
  end
73
82
 
74
- # Ensures `save!` queries are wrapped within multiple db transactions.
75
- # It might be the case that our service is working with several composable
76
- # objects that are gonna be saved into diffent databases, if there is an
77
- # error, all transactions are gonna be rollback regardless of the DB.
83
+ # Nest transactions for composable records. Transactions across distinct
84
+ # database connections are not a distributed atomic commit.
78
85
  def multi_db_transaction(attribute: composable_keys.first, &block)
79
86
  return yield if attribute.nil?
80
87
 
@@ -129,8 +136,6 @@ module Composable
129
136
 
130
137
  # DSL method
131
138
  def sync(*args, to: nil, **options)
132
- args.extract_options!
133
-
134
139
  args.compact.uniq.each do |name|
135
140
  mapping[name.to_sym] ||= Sync.new(from: name)
136
141
  mapping[name.to_sym].merge(to: (to || name), **options)
@@ -197,7 +202,7 @@ module Composable
197
202
  end
198
203
 
199
204
  def call(form, record)
200
- callable.call(form, record).eql?(statement == :if)
205
+ !!callable.call(form, record) == (statement == :if)
201
206
  end
202
207
 
203
208
  private
@@ -208,10 +213,14 @@ module Composable
208
213
  ->(form, _record) { form.send(condition) }
209
214
  when ::Proc
210
215
  lambda { |form, record|
211
- form.instance_exec(*[record][0...condition.arity], &condition)
216
+ if condition.arity == 0
217
+ form.instance_exec(&condition)
218
+ else
219
+ form.instance_exec(record, &condition)
220
+ end
212
221
  }
213
222
  else
214
- ->(*) { true }
223
+ raise ArgumentError, "condition must be a Symbol or Proc"
215
224
  end
216
225
  end
217
226
  end
@@ -10,7 +10,7 @@ module Composable
10
10
  module VERSION
11
11
  MAJOR = 0
12
12
  MINOR = 0
13
- TINY = 13
13
+ TINY = 14
14
14
  PRE = nil
15
15
 
16
16
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
@@ -13,10 +13,8 @@ module Composable
13
13
  attributes.each do |attribute|
14
14
  @inheritable_attributes << {attribute: attribute, default: options[:default]}
15
15
 
16
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
17
- class << self; attr_accessor :#{attribute} end
18
- RUBY
19
- send("#{attribute}=", options[:default]) unless options[:default].nil?
16
+ singleton_class.attr_accessor(attribute)
17
+ send("#{attribute}=", options[:default].deep_dup) unless options[:default].nil?
20
18
  end
21
19
  @inheritable_attributes
22
20
  end
@@ -29,10 +27,8 @@ module Composable
29
27
  attributes.each do |attribute|
30
28
  @uninheritable_attributes << {attribute: attribute, default: options[:default]}
31
29
 
32
- class_eval <<-RUBY, __FILE__, __LINE__ + 1
33
- class << self; attr_accessor :#{attribute} end
34
- RUBY
35
- send("#{attribute}=", options[:default]) unless options[:default].nil?
30
+ singleton_class.attr_accessor(attribute)
31
+ send("#{attribute}=", options[:default].deep_dup) unless options[:default].nil?
36
32
  end
37
33
 
38
34
  @uninheritable_attributes
@@ -45,7 +41,7 @@ module Composable
45
41
  inheritable_attribute = hash[:attribute]
46
42
  instance_name = "@#{inheritable_attribute}"
47
43
  instance_value = instance_variable_get(instance_name).deep_dup
48
- subclass.instance_variable_set(instance_name, instance_value || hash[:default].deep_dup)
44
+ subclass.instance_variable_set(instance_name, instance_value)
49
45
  end
50
46
 
51
47
  Array(@uninheritable_attributes).each do |hash|
@@ -6,11 +6,12 @@ module Composable
6
6
  extend ActiveSupport::Concern
7
7
  include ActiveSupport::Rescuable
8
8
 
9
- class ErrorWithRecord < Exception
9
+ class ErrorWithRecord < StandardError
10
10
  attr_reader :record
11
11
 
12
12
  def initialize(record)
13
13
  @record = record
14
+ super(record.errors.full_messages.to_sentence)
14
15
  end
15
16
  end
16
17
 
@@ -18,9 +19,8 @@ module Composable
18
19
  prepend ComposableDSLExt
19
20
 
20
21
  rescue_from(ErrorWithRecord) do |exception|
21
- exception.record.errors.delete(:base)&.each { |m| errors.add(:base, m) }
22
22
  exception.record.errors.each do |error|
23
- if error.attribute.to_sym.in?(attributes)
23
+ if error.attribute == :base || error.attribute.to_sym.in?(attributes)
24
24
  errors.add(error.attribute, error.message)
25
25
  else
26
26
  errors.add(:base, exception.record.errors.full_message(error.attribute, error.message))
@@ -30,9 +30,11 @@ module Composable
30
30
  end
31
31
 
32
32
  module ComposableDSLExt
33
- def save_composable_records
33
+ def save_composable_records(...)
34
34
  super
35
- rescue ActiveRecord::RecordInvalid => exception
35
+ rescue StandardError => exception
36
+ raise unless defined?(ActiveRecord::RecordInvalid) && exception.is_a?(ActiveRecord::RecordInvalid)
37
+
36
38
  raise ErrorWithRecord.new(exception.record)
37
39
  end
38
40
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "core/version"
4
+ require "set"
4
5
  require "active_support"
5
6
  require "active_support/core_ext/object/deep_dup"
6
7
  require "active_model"
@@ -17,4 +18,4 @@ module Composable
17
18
  end
18
19
  end
19
20
 
20
- require "composable/core/record_invalid" if defined?(ActiveRecord)
21
+ require "composable/core/record_invalid"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: composable-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.13
4
+ version: 0.0.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jairo Vazquez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-08-09 00:00:00.000000000 Z
10
+ date: 2026-10-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '6.1'
18
+ version: '7.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '6.1'
25
+ version: '7.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: activemodel
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '6.1'
32
+ version: '7.2'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '6.1'
39
+ version: '7.2'
40
40
  description: Core composable object to work with ruby services and forms
41
41
  email:
42
42
  - jairovm20@gmail.com
@@ -70,7 +70,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
70
70
  requirements:
71
71
  - - ">="
72
72
  - !ruby/object:Gem::Version
73
- version: 2.7.0
73
+ version: 3.2.0
74
74
  required_rubygems_version: !ruby/object:Gem::Requirement
75
75
  requirements:
76
76
  - - ">="