plumb 0.0.6 → 0.0.8

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: 308e76909c6466b0a6c2cc9443498a267186344b9508b8f485975479e0ff165a
4
- data.tar.gz: 8498e5a4619437b8f91b3baae4b2d208c27031a5617dba174d52893cd4e3a54a
3
+ metadata.gz: 7e5d0ac088968506a61d63da75f4010b050579c873d79604a25dfe0b133d3726
4
+ data.tar.gz: d559c1a3964544184258043e14d0bf9e3b41ff743a8117eccee09be2c22b084a
5
5
  SHA512:
6
- metadata.gz: d41ebdf232099770d04abc85f81ead1e8dc1d4f55eb1bc9265484401cfd0418e984d7cf97a67a6ef452d67f05c3f92e66e3e3fe64f11622acbb89e5c223c73b1
7
- data.tar.gz: 5e2749e954fae81753d63d6d27b95a53f239b5ac6ad776755646d794fe7819b56087f48bd99394aeab2f40c64d45606cc413a1475512f6943279d51a7dd7d2b2
6
+ metadata.gz: 1207b06d90baa9833cf39737f2697fb6daf9423d94ca531a99307de561b70415bb6c9ec7fb2394af6c1f8b3f86015996ebfde5db6dc3fa2aadd5284fb1867cb2
7
+ data.tar.gz: 19131a0a289b8c5082ceb3a9cd716cdabb2d12466bba76ecc5d20c2efd6db3fbb9c04d13c8706996225189de50d81c5d8325fc5527ecc8395c492430fd17393d
data/README.md CHANGED
@@ -294,6 +294,22 @@ NotEmail.parse('hello') # "hello"
294
294
  NotEmail.parse('hello@server.com') # error
295
295
  ```
296
296
 
297
+ `#not` can also be given a type as argument, which might read better:
298
+
299
+ ```ruby
300
+ Types::Any.not(nil)
301
+ Types::Any.not(Types::Email)
302
+ ```
303
+
304
+ Finally, you can use `Types::Not` for the same effect.
305
+
306
+ ```ruby
307
+ NotNil = Types::Not[nil]
308
+ NotNil.parse(1) # 1
309
+ NotNil.parse('hello') # 'hello'
310
+ NotNil.parse(nil) # error
311
+ ```
312
+
297
313
  #### `#options`
298
314
 
299
315
  Sets allowed options for value.
@@ -455,6 +471,71 @@ All scalar types support this:
455
471
  ten = Types::Integer.value(10)
456
472
  ```
457
473
 
474
+ #### `#static`
475
+
476
+ A type that always returns a valid, static value, regardless of input.
477
+
478
+ ```ruby
479
+ ten = Types::Integer.static(10)
480
+ ten.parse(10) # => 10
481
+ ten.parse(100) # => 10
482
+ ten.parse('hello') # => 10
483
+ ten.parse() # => 10
484
+ ten.metadata[:type] # => Integer
485
+ ```
486
+
487
+ Useful for data structures where some fields shouldn't change. Example:
488
+
489
+ ```ruby
490
+ CreateUserEvent = Types::Hash[
491
+ type: Types::String.static('CreateUser'),
492
+ name: String,
493
+ age: Integer
494
+ ]
495
+ ```
496
+
497
+ Note that the value must be of the same type as the starting step's target type.
498
+
499
+ ```ruby
500
+ Types::Integer.static('nope') # raises ArgumentError
501
+ ```
502
+
503
+ This usage is similar as using `Types::Static['hello']`directly.
504
+
505
+ This helper is shorthand for the following composition:
506
+
507
+ ```ruby
508
+ Types::Static[value] >> step
509
+ ```
510
+
511
+ This means that validations and coercions in the original step are still applied to the static value.
512
+
513
+ ```ruby
514
+ ten = Types::Integer[100..].static(10)
515
+ ten.parse # => Plumb::ParseError "Must be within 100..."
516
+ ```
517
+
518
+ So, normally you'd only use this attached to primitive types without further processing (but your use case may vary).
519
+
520
+ #### `#generate`
521
+
522
+ Passing a proc will evaluate the proc on every invocation. Use this for generated values.
523
+
524
+ ```ruby
525
+ random_number = Types::Numeric.generate { rand }
526
+ random_number.parse # 0.32332
527
+ random_number.parse('foo') # 0.54322 etc
528
+ ```
529
+
530
+ Note that the type of generated value must match the initial step's type, validated at invocation.
531
+
532
+ ```ruby
533
+ random_number = Types::String.generate { rand } # this won't raise an error here
534
+ random_number.parse # raises Plumb::ParseError because `rand` is not a String
535
+ ```
536
+
537
+ You can also pass any `#call() => Object` interface as a generator, instead of a proc.
538
+
458
539
  #### `#metadata`
459
540
 
460
541
  Add metadata to a type
@@ -1135,6 +1216,30 @@ Payload = Types::Hash[
1135
1216
  ]
1136
1217
  ```
1137
1218
 
1219
+ #### Attribute writers
1220
+
1221
+ By default `Types::Data` classes are inmutable, but you can define attribute writers to allow for mutation using the `writer: true` option.
1222
+
1223
+ ```ruby
1224
+ class DBConfig < Types::Data
1225
+ attribute :host, Types::String.default('localhost'), writer: true
1226
+ end
1227
+
1228
+ class Config < Types::Data
1229
+ attribute :host, Types::Forms::URI::HTTP, writer: true
1230
+ attribute :port, Types::Integer.default(80), writer: true
1231
+
1232
+ # Nested structs can have writers too
1233
+ attribute :db, DBConfig.default(DBConfig.new)
1234
+ end
1235
+
1236
+ config = Config.new
1237
+ config.host = 'http://localhost'
1238
+ config.db.host = 'db.local'
1239
+ config.valid? # true
1240
+ config.errors # {}
1241
+ ```
1242
+
1138
1243
  #### Recursive struct definitions
1139
1244
 
1140
1245
  You can use `#defer`. See [recursive types](#recursive-types).
@@ -14,9 +14,6 @@ module Types
14
14
  # Turn an ISO8601 string into a Time object
15
15
  ISOTime = String.build(::Time, :parse).policy(:rescue, ArgumentError)
16
16
 
17
- # A type that can be a Time object or an ISO8601 string >> Time
18
- Time = Any[::Time] | ISOTime
19
-
20
17
  # A UUID string, or generate a new one
21
18
  AutoUUID = UUID::V4.default { SecureRandom.uuid }
22
19
  end
@@ -60,7 +57,7 @@ class Event < Types::Data
60
57
  attribute :id, Types::AutoUUID
61
58
  attribute :stream_id, Types::String.present
62
59
  attribute :type, Types::String
63
- attribute(:created_at, Types::Time.default { ::Time.now })
60
+ attribute(:created_at, Types::Forms::Time.default { ::Time.now })
64
61
  attribute? :causation_id, Types::UUID::V4
65
62
  attribute? :correlation_id, Types::UUID::V4
66
63
  attribute :payload, Types::Static[nil]
@@ -45,7 +45,7 @@ module Plumb
45
45
  def call(result)
46
46
  return result.invalid(errors: 'is not an Array') unless ::Array === result.value
47
47
 
48
- values, errors = map_array_elements(result.value)
48
+ values, errors = map_array_elements(result)
49
49
  return result.valid(values) unless errors.any?
50
50
 
51
51
  result.invalid(values, errors:)
@@ -59,14 +59,14 @@ module Plumb
59
59
  %(Array[#{element_type}])
60
60
  end
61
61
 
62
- def map_array_elements(list)
62
+ def map_array_elements(result)
63
63
  # Reuse the same result object for each element
64
64
  # to decrease object allocation.
65
65
  # Steps might return the same result instance, so we map the values directly
66
66
  # separate from the errors.
67
- element_result = BLANK_RESULT.dup
67
+ element_result = result.dup
68
68
  errors = {}
69
- values = list.map.with_index do |e, idx|
69
+ values = result.value.map.with_index do |e, idx|
70
70
  re = element_type.call(element_result.reset(e))
71
71
  errors[idx] = re.errors unless re.valid?
72
72
  re.value
@@ -78,12 +78,12 @@ module Plumb
78
78
  class ConcurrentArrayClass < self
79
79
  private
80
80
 
81
- def map_array_elements(list)
81
+ def map_array_elements(result)
82
82
  errors = {}
83
83
 
84
- values = list
85
- .map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
86
- .map.with_index do |f, idx|
84
+ values = result.value
85
+ .map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
86
+ .map.with_index do |f, idx|
87
87
  re = f.value
88
88
  errors[idx] = f.reason if f.rejected?
89
89
  re.value
@@ -117,6 +117,7 @@ module Plumb
117
117
 
118
118
  def initialize(attrs = {})
119
119
  assign_attributes(attrs)
120
+ freeze
120
121
  end
121
122
 
122
123
  def ==(other)
@@ -140,13 +141,18 @@ module Plumb
140
141
 
141
142
  # @return [Hash]
142
143
  def to_h
143
- attributes.transform_values do |value|
144
- case value
145
- when ::Array
146
- value.map { |v| v.respond_to?(:to_h) ? v.to_h : v }
147
- else
148
- value.respond_to?(:to_h) ? value.to_h : value
149
- end
144
+ self.class._schema._schema.keys.each.with_object({}) do |key, memo|
145
+ key = key.to_sym
146
+ value = attributes[key]
147
+ val = case value
148
+ when ::Array
149
+ value.map { |v| v.respond_to?(:to_h) ? v.to_h : v }
150
+ when ::NilClass
151
+ nil
152
+ else
153
+ value.respond_to?(:to_h) ? value.to_h : value
154
+ end
155
+ memo[key] = val
150
156
  end
151
157
  end
152
158
 
@@ -158,12 +164,14 @@ module Plumb
158
164
  def assign_attributes(attrs = BLANK_HASH)
159
165
  raise ArgumentError, 'Must be a Hash of attributes' unless attrs.respond_to?(:to_h)
160
166
 
161
- @errors = BLANK_HASH
167
+ @errors = {}
162
168
  result = self.class._schema.resolve(attrs.to_h)
163
- @attributes = result.value
169
+ @attributes = prepare_attributes(result.value)
164
170
  @errors = result.errors unless result.valid?
165
171
  end
166
172
 
173
+ def prepare_attributes(attrs) = attrs
174
+
167
175
  module ClassMethods
168
176
  def _schema
169
177
  @_schema ||= HashClass.new
@@ -209,7 +217,7 @@ module Plumb
209
217
  # attribute(:friends, Types::Array[Person])
210
218
  # attribute(:friends, [Person])
211
219
  #
212
- def attribute(name, type = Types::Any, &block)
220
+ def attribute(name, type = Types::Any, writer: false, &block)
213
221
  key = Key.wrap(name)
214
222
  name = key.to_sym
215
223
  type = Composable.wrap(type)
@@ -232,13 +240,30 @@ module Plumb
232
240
  end
233
241
 
234
242
  @_schema = _schema + { key => type }
235
- __plumb_define_attribute_method__(name)
243
+ __plumb_define_attribute_reader_method__(name)
244
+ return name unless writer
245
+
246
+ __plumb_define_attribute_writer_method__(name)
236
247
  end
237
248
 
238
- def __plumb_define_attribute_method__(name)
249
+ def __plumb_define_attribute_reader_method__(name)
239
250
  define_method(name) { @attributes[name] }
240
251
  end
241
252
 
253
+ def __plumb_define_attribute_writer_method__(name)
254
+ define_method("#{name}=") do |value|
255
+ type = self.class._schema.at_key(name)
256
+ result = type.resolve(value)
257
+ @attributes[name] = result.value
258
+ if result.valid?
259
+ @errors.delete(name)
260
+ else
261
+ @errors.merge!(name => result.errors)
262
+ end
263
+ result.value
264
+ end
265
+ end
266
+
242
267
  def attribute?(name, *args, &block)
243
268
  attribute(Key.new(name, optional: true), *args, &block)
244
269
  end
@@ -84,6 +84,26 @@ module Plumb
84
84
  def node_name = self.class.name.split('::').last.to_sym
85
85
  end
86
86
 
87
+ # Override #=== and #== for Composable instances.
88
+ # but only when included in classes, not extended.
89
+ module Equality
90
+ # `#===` equality. So that Plumb steps can be used in case statements and pattern matching.
91
+ # @param other [Object]
92
+ # @return [Boolean]
93
+ def ===(other)
94
+ case other
95
+ when Composable
96
+ other == self
97
+ else
98
+ resolve(other).valid?
99
+ end
100
+ end
101
+
102
+ def ==(other)
103
+ other.is_a?(self.class) && other.respond_to?(:children) && other.children == children
104
+ end
105
+ end
106
+
87
107
  #  Composable mixes in composition methods to classes.
88
108
  # such as #>>, #|, #not, and others.
89
109
  # Any Composable class can participate in Plumb compositions.
@@ -95,6 +115,7 @@ module Plumb
95
115
  # not extending classes with it.
96
116
  def self.included(base)
97
117
  base.send(:include, Naming)
118
+ base.send(:include, Equality)
98
119
  end
99
120
 
100
121
  # Wrap an object in a Composable instance.
@@ -304,22 +325,6 @@ module Plumb
304
325
  end
305
326
  end
306
327
 
307
- # `#===` equality. So that Plumb steps can be used in case statements and pattern matching.
308
- # @param other [Object]
309
- # @return [Boolean]
310
- def ===(other)
311
- case other
312
- when Composable
313
- other == self
314
- else
315
- resolve(other).valid?
316
- end
317
- end
318
-
319
- def ==(other)
320
- other.is_a?(self.class) && other.respond_to?(:children) && other.children == children
321
- end
322
-
323
328
  # Visitors expect a #node_name and #children interface.
324
329
  # @return [Array<Composable>]
325
330
  def children = BLANK_ARRAY
@@ -341,6 +346,40 @@ module Plumb
341
346
  self >> Build.new(cns, factory_method:, &block)
342
347
  end
343
348
 
349
+ # Always return a static value, regardless of the input.
350
+ # @example
351
+ # type = Types::Integer.static(10)
352
+ # type.parse(10) # => 10
353
+ # type.parse(100) # => 10
354
+ # type.parse # => 10
355
+ #
356
+ # @param value [Object]
357
+ # @return [And]
358
+ def static(value)
359
+ my_type = Array(metadata[:type]).first
360
+ unless my_type.nil? || value.instance_of?(my_type)
361
+ raise ArgumentError,
362
+ "can't set a static #{value.class} value for a #{my_type} step"
363
+ end
364
+
365
+ StaticClass.new(value) >> self
366
+ end
367
+
368
+ # Return the output of a block or #call interface, regardless of input.
369
+ # The block will be called to get the value, on every invocation.
370
+ # @example
371
+ # now = Types::Integer.generate { Time.now.to_i }
372
+ #
373
+ # @param generator [#call, nil] a callable that will be applied to the value, or nil if block
374
+ # @param block [Proc] a block that will be applied to the value, or nil if callable
375
+ # @return [And]
376
+ def generate(generator = nil, &block)
377
+ generator ||= block
378
+ raise ArgumentError, 'expected a generator' unless generator.respond_to?(:call)
379
+
380
+ Step.new(->(r) { r.valid(generator.call) }, 'generator') >> self
381
+ end
382
+
344
383
  # Build a Plumb::Pipeline with this object as the starting step.
345
384
  # @example
346
385
  # pipe = Types::Data[name: String].pipeline do |pl|
@@ -351,7 +390,7 @@ module Plumb
351
390
  #
352
391
  # @return [Pipeline]
353
392
  def pipeline(&block)
354
- Pipeline.new(self, &block)
393
+ Pipeline.new(type: self, &block)
355
394
  end
356
395
 
357
396
  def to_s
@@ -109,7 +109,7 @@ module Plumb
109
109
 
110
110
  input = result.value
111
111
  errors = {}
112
- field_result = BLANK_RESULT.dup
112
+ field_result = result.dup
113
113
  initial = {}
114
114
  initial = initial.merge(input) if @inclusive
115
115
  output = _schema.each.with_object(initial) do |(key, field), ret|
data/lib/plumb/not.rb CHANGED
@@ -8,13 +8,19 @@ module Plumb
8
8
 
9
9
  attr_reader :children, :errors
10
10
 
11
- def initialize(step, errors: nil)
12
- @step = step
13
- @errors = errors
11
+ def initialize(step = nil, errors: nil)
12
+ @step = Composable.wrap(step)
13
+ @errors = errors || "must not be #{step.inspect}"
14
14
  @children = [step].freeze
15
15
  freeze
16
16
  end
17
17
 
18
+ # @param step [Object]
19
+ # @return [Not]
20
+ def [](step)
21
+ self.class.new(step)
22
+ end
23
+
18
24
  private def _inspect
19
25
  %(Not(#{@step.inspect}))
20
26
  end
@@ -37,14 +37,14 @@ module Plumb
37
37
 
38
38
  attr_reader :children
39
39
 
40
- def initialize(type = Types::Any, &setup)
40
+ def initialize(type: Types::Any, freeze_after: true, &setup)
41
41
  @type = type
42
42
  @children = [type].freeze
43
43
  @around_blocks = self.class.around_blocks.dup
44
44
  return unless block_given?
45
45
 
46
46
  configure(&setup)
47
- freeze
47
+ freeze if freeze_after
48
48
  end
49
49
 
50
50
  def call(result)
data/lib/plumb/types.rb CHANGED
@@ -159,6 +159,7 @@ module Plumb
159
159
  Stream = StreamClass.new
160
160
  Tuple = TupleClass.new
161
161
  Hash = HashClass.new
162
+ Not = Plumb::Not.new
162
163
  Interface = InterfaceClass.new
163
164
  Email = String[URI::MailTo::EMAIL_REGEXP].as_node(:email)
164
165
  Date = Any[::Date]
data/lib/plumb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plumb
4
- VERSION = '0.0.6'
4
+ VERSION = '0.0.8'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plumb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.6
4
+ version: 0.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-09-04 00:00:00.000000000 Z
11
+ date: 2024-10-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal
@@ -115,7 +115,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
115
115
  - !ruby/object:Gem::Version
116
116
  version: '0'
117
117
  requirements: []
118
- rubygems_version: 3.5.11
118
+ rubygems_version: 3.5.21
119
119
  signing_key:
120
120
  specification_version: 4
121
121
  summary: Data validation and transformation library.