zen-service 2.2.4 → 2.3.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: 7c1c54fed5a49c87cdbdfbaf9cb369e8eed0ac2a0e1862eadd5edca95af1f931
4
- data.tar.gz: 2c4d4efaaa636193e3d4daa6d94a375e1e67f7c8bfc0ab930d8cedbd36023012
3
+ metadata.gz: b540abd37b4fb43937241ab38aab9e143afc8afa80683002ccc32dace163ab7c
4
+ data.tar.gz: 9d0a5e5a0e6bbf2e2473bc8b3337edab1ea65773d163336a53a1ef9741a95745
5
5
  SHA512:
6
- metadata.gz: 24394d2ecd8f0af333c82b23089b5c53b2481db6cee8ebffc78f9f69b80fc371c58020064d1650453f5a146bd789d76e6db62b90a4885ad34fcc7af57959ce07
7
- data.tar.gz: 746b18aaa03abe11e5c194e6817da0b84e1e18c1226f2f67e7f6a981bf9dfc331ff422afc8518e08ea11bdb58ec6f79b0dac34b855335eb2cdb361e2912b85b5
6
+ metadata.gz: c5c28f74692f0ea39242111cf419c116d51586945f08e5523996f40d578884a8797934ca153251f08802b464569916e898b13687648b27ed100895baf528e011
7
+ data.tar.gz: a5fd404f08a6ea273d4a73bffdfeb9aec2a78cd9894ee600338de80f0b87892a825eeaf592d3f44772b137a5f3cea8c27f679140a093a80920575154ddca51dd
data/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.3.0] - 2026-02-02
9
+
10
+ ### Added
11
+
12
+ - Add `:inputs` plugin as an experimental alternative to `:attributes` for service initialization
13
+ - Provides keyword-only initialization with built-in runtime validation
14
+ - Supports per-input validation blocks with Ruby 3+ pattern matching
15
+ - Includes optional inputs with lazy-evaluated defaults
16
+ - Adds initialization blocks for computed attributes
17
+ - Designed for use with `Zen::Service::Callable` descendants
18
+ - Add `Zen::Service::Callable` base class as service "blank slate"
19
+ - Inherits only `:callable` plugin without `:attributes`
20
+ - Enables alternative initialization strategies via plugins like `:inputs`
21
+ - Add comprehensive benchmark comparison with `verbalize` gem in README
22
+ - Shows `zen-service` with `:attributes` is ~31% faster than `verbalize`
23
+ - Shows `:inputs` plugin is ~8% faster than `verbalize`
24
+
25
+ ### Changed
26
+
27
+ - Update README with `:inputs` plugin documentation and usage examples
28
+ - Update README with pattern matching validation examples
29
+ - Improve documentation structure and clarity
30
+
8
31
  ## [2.2.4] - 2026-01-19
9
32
 
10
33
  ### Fixed
@@ -94,6 +117,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
94
117
  - Most built-in plugins from v1.x for simplicity
95
118
  - Removed legacy plugin APIs
96
119
 
120
+ [2.2.4]: https://github.com/akuzko/zen-service/compare/v2.2.3...v2.2.4
121
+ [2.3.0]: https://github.com/akuzko/zen-service/compare/v2.2.4...v2.3.0
97
122
  [2.2.4]: https://github.com/akuzko/zen-service/compare/v2.2.3...v2.2.4
98
123
  [2.2.3]: https://github.com/akuzko/zen-service/compare/v2.2.2...v2.2.3
99
124
  [2.2.2]: https://github.com/akuzko/zen-service/compare/v2.2.1...v2.2.2
data/README.md CHANGED
@@ -150,9 +150,11 @@ class Logger < Zen::Service
150
150
 
151
151
  # Will result with value return by `yield` expression
152
152
  def call
153
+ start_time = Time.now
153
154
  Rails.logger.info("Starting operation")
154
- result = yield
155
- Rails.logger.info("Operation completed: #{result.inspect}")
155
+ yield
156
+ time_taken = (Time.now - start_time) * 1000
157
+ Rails.logger.info("Operation completed in #{time_taken.round} ms")
156
158
  end
157
159
  end
158
160
 
@@ -170,6 +172,114 @@ class UpdateTodo < Zen::Service
170
172
  end
171
173
  ```
172
174
 
175
+ #### `:inputs` (Experimental)
176
+
177
+ Provides an alternative way to initialize services with keyword-only arguments and built-in runtime validation.
178
+
179
+ **Key Features:**
180
+
181
+ - Keyword-only initialization (no positional arguments)
182
+ - Per-input validation blocks with Ruby 3+ pattern matching
183
+ - Optional inputs with lazy-evaluated defaults
184
+ - Initialization blocks for computed attributes
185
+
186
+ **⚠️ Experimental Feature:** To avoid breaking changes, services using `:inputs` should inherit from
187
+ `Zen::Service::Callable` instead of `Zen::Service`. The base `Zen::Service` class already includes the
188
+ `:attributes` plugin, and these two plugins provide different initialization strategies.
189
+ API of the plugin may be changed in future.
190
+
191
+ ```ruby
192
+ # Base class for input-based services
193
+ class ApplicationCallable < Zen::Service::Callable
194
+ use :inputs
195
+ end
196
+
197
+ # Service with input validation
198
+ class CalculatePrice < ApplicationCallable
199
+ input(:quantity) { _1 => Integer }
200
+ input(:unit_price) { _1 => Numeric }
201
+ input(:discount, optional: true)
202
+
203
+ def call
204
+ total = quantity * unit_price
205
+ discount ? total * (1 - discount) : total
206
+ end
207
+ end
208
+
209
+ CalculatePrice.call(quantity: 10, unit_price: 5.0, discount: 0.1)
210
+ # => 45.0
211
+
212
+ CalculatePrice.call(quantity: "10", unit_price: 5.0)
213
+ # => NoMatchingPatternError (Integer === "10" does not return true)
214
+ ```
215
+
216
+ **Input Options:**
217
+
218
+ - `optional: true` - Allow input to be omitted (defaults to `nil`)
219
+ - `default: -> { value }` - Provide lazy-evaluated default value
220
+
221
+ **Bulk Definition with Validation:**
222
+
223
+ ```ruby
224
+ class ProcessCoordinates < ApplicationCallable
225
+ inputs(:x, :y) do |x_val, y_val|
226
+ x_val => Integer
227
+ y_val => Integer
228
+ raise ArgumentError, "coordinates out of bounds" if x_val.abs > 100 || y_val.abs > 100
229
+ end
230
+
231
+ def call
232
+ [x, y]
233
+ end
234
+ end
235
+
236
+ ProcessCoordinates.call(x: 10, y: 20) # => [10, 20]
237
+ ProcessCoordinates.call(x: 150, y: 20) # => ArgumentError: coordinates out of bounds
238
+ ```
239
+
240
+ **Default Values:**
241
+
242
+ ```ruby
243
+ class CreateReport < ApplicationCallable
244
+ input :data
245
+ input :format, default: -> { :json }
246
+ input :timestamp, default: -> { Time.current }
247
+
248
+ def call
249
+ { data: data, format: format, timestamp: timestamp }
250
+ end
251
+ end
252
+
253
+ CreateReport.call(data: [1, 2, 3])
254
+ # => { data: [1, 2, 3], format: :json, timestamp: 2026-02-02 10:30:00 UTC }
255
+ ```
256
+
257
+ **Pattern Matching for Type Safety:**
258
+
259
+ The primary use case for validation blocks is runtime type checking using Ruby's pattern matching:
260
+
261
+ ```ruby
262
+ class UserRegistration < ApplicationCallable
263
+ input(:email) { _1 => String }
264
+ input(:age) { _1 => Integer if _1 >= 18 }
265
+ input(:role) { _1 => :admin | :user | :guest }
266
+
267
+ def call
268
+ # Your registration logic
269
+ end
270
+ end
271
+
272
+ # Valid calls
273
+ UserRegistration.call(email: "user@example.com", age: 25, role: :user)
274
+
275
+ # Pattern match failures
276
+ UserRegistration.call(email: 123, age: 25, role: :user)
277
+ # => NoMatchingPatternError
278
+
279
+ UserRegistration.call(email: "user@example.com", age: 15, role: :user)
280
+ # => NoMatchingPatternError
281
+ ```
282
+
173
283
  ### Creating Custom Plugins
174
284
 
175
285
  Creating custom plugins is straightforward. Below is an example of a plugin that transforms results to
@@ -273,6 +383,76 @@ module MyPlugin
273
383
  end
274
384
  ```
275
385
 
386
+ ## Comparison/Benchmark
387
+
388
+ `zen-service` is designed to be both flexible and performant. Among similar service object gems,
389
+ [verbalize](https://github.com/taylorzr/verbalize) is known for being the fastest implementation.
390
+ The following benchmark compares `zen-service` with `verbalize` using a simple addition service:
391
+
392
+ ```ruby
393
+ require 'benchmark/ips'
394
+ require 'verbalize'
395
+ require 'zen/service'
396
+
397
+ class VerbalizeAdd
398
+ include Verbalize::Action
399
+
400
+ input :a, :b
401
+
402
+ def call
403
+ a + b
404
+ end
405
+ end
406
+
407
+ class ZenAdd < Zen::Service
408
+ attributes :a, :b
409
+
410
+ def call
411
+ a + b
412
+ end
413
+ end
414
+
415
+ class ZenInputsAdd < Zen::Service::Callable
416
+ use :inputs
417
+
418
+ inputs :a, :b
419
+
420
+ def call
421
+ a + b
422
+ end
423
+ end
424
+
425
+ Benchmark.ips do |x|
426
+ x.report('Verbalize ') { VerbalizeAdd.call(a: 1, b: 2) }
427
+ x.report('Zen (attributes)') { ZenAdd.call(a: 1, b: 2) }
428
+ x.report('Zen (inputs) ') { ZenInputsAdd.call(a: 1, b: 2) }
429
+ x.compare!
430
+ end
431
+ ```
432
+
433
+ **Results:**
434
+
435
+ ```
436
+ Warming up --------------------------------------
437
+ Verbalize 66.313k i/100ms
438
+ Zen (attributes) 87.104k i/100ms
439
+ Zen (inputs) 62.857k i/100ms
440
+ Calculating -------------------------------------
441
+ Verbalize 648.660k (± 2.4%) i/s (1.54 μs/i) - 3.249M in 5.012220s
442
+ Zen (attributes) 848.953k (± 4.2%) i/s (1.18 μs/i) - 4.268M in 5.037257s
443
+ Zen (inputs) 701.096k (± 3.1%) i/s (1.43 μs/i) - 3.520M in 5.026014s
444
+
445
+ Comparison:
446
+ Zen (attributes): 848952.8 i/s
447
+ Zen (inputs) : 701095.7 i/s - 1.21x slower
448
+ Verbalize : 648660.4 i/s - 1.31x slower
449
+ ```
450
+
451
+ `zen-service` with the default `:attributes` plugin outperforms `verbalize` by ~31%, while the
452
+ experimental `:inputs` plugin is ~8% faster than `verbalize`. These benchmarks demonstrate that
453
+ `zen-service` provides excellent performance while offering superior extensibility through its
454
+ plugin system.
455
+
276
456
  ## Testing
277
457
 
278
458
  The gem has 100% test coverage with both line and branch coverage. To run the test suite:
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zen
4
+ class Service
5
+ class Callable
6
+ extend Plugins::Pluggable
7
+
8
+ use :callable
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zen
4
+ module Service::Plugins
5
+ module Inputs
6
+ extend Plugin
7
+
8
+ class Input < Data.define(:name, :optional, :default, :block)
9
+ def default_or_raise!
10
+ return default&.call if optional?
11
+
12
+ raise(ArgumentError, "input #{name} is required")
13
+ end
14
+
15
+ def optional?
16
+ !default.nil? || optional
17
+ end
18
+ end
19
+
20
+ InitInputsBlock = Data.define(:input_names, :block)
21
+
22
+ module ClassMethods
23
+ def inherited(service_class)
24
+ service_class.inputs_list.replace(inputs_list.dup)
25
+ service_class.init_inputs_blocks.replace(init_inputs_blocks.dup)
26
+ super
27
+ end
28
+
29
+ def input(name, optional: false, default: nil, &block)
30
+ inputs_list.push(Input.new(name, optional, default, block))
31
+ define_method(name) { @inputs.fetch(name) }
32
+ end
33
+
34
+ def inputs(*args, **kwargs, &block)
35
+ args.each { input(_1) }
36
+ kwargs.each { |name, default| input(name, default: default) }
37
+ init_inputs_blocks.push(InitInputsBlock.new(args + kwargs.keys, block)) if block
38
+ end
39
+
40
+ def inputs_list
41
+ @inputs_list ||= []
42
+ end
43
+
44
+ def input_names
45
+ inputs_list.map(&:name)
46
+ end
47
+
48
+ def init_inputs_blocks
49
+ @init_inputs_blocks ||= []
50
+ end
51
+ end
52
+
53
+ attr_reader :inputs
54
+
55
+ def initialize(**kwargs)
56
+ @inputs = assert_valid_inputs!(kwargs)
57
+ self.class.init_inputs_blocks.each do |init_block|
58
+ instance_exec(*inputs.values_at(*init_block.input_names), &init_block.block)
59
+ end
60
+ super()
61
+ end
62
+
63
+ def initialize_clone(*)
64
+ super
65
+ @inputs = @inputs.dup
66
+ end
67
+
68
+ private
69
+
70
+ def assert_valid_inputs!(actual) # rubocop:disable Metrics/AbcSize
71
+ unexpected = actual.keys - self.class.input_names
72
+ raise(ArgumentError, "wrong inputs #{unexpected.join(', ')} given") if unexpected.any?
73
+
74
+ self.class.inputs_list.each_with_object({}) do |reflection, result|
75
+ input_name = reflection.name
76
+ result[input_name] = actual.key?(input_name) ? actual[input_name] : reflection.default_or_raise!
77
+ instance_exec(result[input_name], &reflection.block) if reflection.block
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -30,7 +30,7 @@ module Zen
30
30
 
31
31
  def plugins
32
32
  ancestors
33
- .select { |klass| klass <= ::Zen::Service }
33
+ .select { |klass| klass <= ::Zen::Service || klass <= ::Zen::Service::Callable }
34
34
  .flat_map(&:service_plugins)
35
35
  .reverse
36
36
  .reduce(&:merge)
@@ -41,6 +41,7 @@ module Zen
41
41
  require_relative "plugins/pluggable"
42
42
  require_relative "plugins/callable"
43
43
  require_relative "plugins/attributes"
44
+ require_relative "plugins/inputs"
44
45
  require_relative "plugins/persisted_result"
45
46
  require_relative "plugins/result_yielding"
46
47
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Zen
4
4
  class Service
5
- VERSION = "2.2.4"
5
+ VERSION = "2.3.0"
6
6
  end
7
7
  end
data/lib/zen/service.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "service/version"
4
4
  require_relative "service/plugins"
5
+ require_relative "service/callable"
5
6
 
6
7
  module Zen
7
8
  class Service
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zen-service
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.4
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artem Kuzko
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-19 00:00:00.000000000 Z
11
+ date: 2026-02-02 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Flexible and highly extensible Services for business logic organization
14
14
  email:
@@ -31,9 +31,11 @@ files:
31
31
  - bin/console
32
32
  - bin/setup
33
33
  - lib/zen/service.rb
34
+ - lib/zen/service/callable.rb
34
35
  - lib/zen/service/plugins.rb
35
36
  - lib/zen/service/plugins/attributes.rb
36
37
  - lib/zen/service/plugins/callable.rb
38
+ - lib/zen/service/plugins/inputs.rb
37
39
  - lib/zen/service/plugins/persisted_result.rb
38
40
  - lib/zen/service/plugins/pluggable.rb
39
41
  - lib/zen/service/plugins/plugin.rb