plumb 0.0.18 → 0.2.0.beta.2

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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +887 -64
  3. data/bench/compare_dry_schema.rb +79 -0
  4. data/bench/compare_dry_types.rb +37 -0
  5. data/bench/compare_parametric_schema.rb +2 -80
  6. data/bench/dry_schema_hash.rb +103 -0
  7. data/bench/dry_types_hash.rb +125 -0
  8. data/bench/json_schema_profile.rb +107 -0
  9. data/bench/plumb_hash.rb +17 -11
  10. data/bench/results_allocations.rb +137 -0
  11. data/bench/sample_data.rb +78 -0
  12. data/examples/command_objects.rb +1 -1
  13. data/examples/concurrent_downloads.rb +16 -9
  14. data/examples/event_registry.rb +6 -1
  15. data/examples/weekdays.rb +1 -1
  16. data/lib/plumb/and.rb +63 -6
  17. data/lib/plumb/any_class.rb +12 -2
  18. data/lib/plumb/array_class.rb +133 -25
  19. data/lib/plumb/attribute_value_match.rb +41 -1
  20. data/lib/plumb/attributes.rb +59 -19
  21. data/lib/plumb/codec.rb +886 -0
  22. data/lib/plumb/composable.rb +451 -39
  23. data/lib/plumb/conjunction.rb +50 -0
  24. data/lib/plumb/constraint.rb +234 -0
  25. data/lib/plumb/covariant_fusion.rb +46 -0
  26. data/lib/plumb/decorator.rb +12 -22
  27. data/lib/plumb/deferred.rb +13 -5
  28. data/lib/plumb/disjunction.rb +112 -0
  29. data/lib/plumb/encoder.rb +207 -0
  30. data/lib/plumb/function.rb +347 -0
  31. data/lib/plumb/hash_class.rb +339 -32
  32. data/lib/plumb/hash_map.rb +58 -14
  33. data/lib/plumb/implementation.rb +247 -0
  34. data/lib/plumb/interface_class.rb +21 -2
  35. data/lib/plumb/intersection.rb +47 -0
  36. data/lib/plumb/json_schema_visitor.rb +255 -36
  37. data/lib/plumb/key.rb +63 -13
  38. data/lib/plumb/mermaid_visitor.rb +129 -0
  39. data/lib/plumb/metadata.rb +10 -1
  40. data/lib/plumb/metadata_visitor.rb +36 -34
  41. data/lib/plumb/never_class.rb +38 -0
  42. data/lib/plumb/node_mapper.rb +97 -0
  43. data/lib/plumb/not.rb +34 -2
  44. data/lib/plumb/optimizer.rb +444 -0
  45. data/lib/plumb/or.rb +26 -29
  46. data/lib/plumb/pipeline.rb +99 -11
  47. data/lib/plumb/policy.rb +17 -4
  48. data/lib/plumb/range_class.rb +46 -0
  49. data/lib/plumb/relation.rb +57 -0
  50. data/lib/plumb/result.rb +55 -23
  51. data/lib/plumb/semantic_matcher.rb +393 -0
  52. data/lib/plumb/static_class.rb +20 -1
  53. data/lib/plumb/stream_class.rb +28 -6
  54. data/lib/plumb/subtyping.rb +461 -0
  55. data/lib/plumb/tagged_hash.rb +45 -4
  56. data/lib/plumb/tuple_class.rb +21 -4
  57. data/lib/plumb/type_cache.rb +41 -0
  58. data/lib/plumb/type_registry.rb +71 -0
  59. data/lib/plumb/typed_step.rb +67 -0
  60. data/lib/plumb/types.rb +44 -43
  61. data/lib/plumb/union.rb +30 -0
  62. data/lib/plumb/value_class.rb +20 -1
  63. data/lib/plumb/version.rb +1 -1
  64. data/lib/plumb/visitor_handlers.rb +20 -4
  65. data/lib/plumb.rb +90 -3
  66. metadata +30 -8
  67. data/lib/plumb/build.rb +0 -22
  68. data/lib/plumb/match_class.rb +0 -42
  69. data/lib/plumb/schema.rb +0 -195
  70. data/lib/plumb/step.rb +0 -27
  71. data/lib/plumb/transform.rb +0 -26
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Surfaces the throwaway work Plumb does while parsing — Result objects, error
4
+ # strings and containers that are built and then discarded.
5
+ #
6
+ # The headline metric is INVALID TRANSITIONS: how many times a Result is flipped to
7
+ # invalid during a parse, including SUCCESSFUL ones. A value matching a non-first
8
+ # branch of a union (`A | B`, `Lax::*`, `nullable`, defaults, enums) pays for every
9
+ # branch it fell through, because `Or#call` tries each in turn and each miss runs,
10
+ # builds an error payload, and flips the cursor.
11
+ #
12
+ # This counts flips rather than `Result::Invalid` objects because there is no such
13
+ # class: Valid and Invalid were collapsed into one Result carrying a boolean, which
14
+ # is what lets the built-ins reuse the cursor in place (`#invalid!`) instead of
15
+ # allocating one per miss. That collapse removed the ALLOCATION but not the WORK,
16
+ # and the work is what this bench is about — so the flip is the honest successor to
17
+ # the old object count.
18
+ #
19
+ # The `Result` row is the other half of the picture, and shows the collapse paying
20
+ # off: it stays FLAT across all three regimes (4 per record) while the flips go
21
+ # 0 -> 5 -> 14. Misses cost work, but no longer cost an allocation.
22
+ #
23
+ # Three payload regimes are run over the SAME schema so the difference is purely
24
+ # the data, not the types:
25
+ # 1. first-branch valid — every union matches its FIRST branch (the floor)
26
+ # 2. union-hit valid — valid OUTPUT, but unions match LATER branches
27
+ # 3. fully invalid — every field fails; Invalid propagates + errors aggregate
28
+ #
29
+ # ruby bench/results_allocations.rb # N=2000 records per regime
30
+ # N=20000 ruby bench/results_allocations.rb # scale the sample
31
+
32
+ require 'bundler'
33
+ Bundler.setup(:benchmark)
34
+ require 'plumb'
35
+ require 'memory_profiler'
36
+
37
+ # Count every flip to invalid, so the per-record figure is exact rather than
38
+ # sampled. Both forms are counted: #invalid allocates a fresh Result (the safe form
39
+ # user code uses), #invalid! flips the receiver (the built-ins' hot path).
40
+ $invalid_transitions = 0
41
+ module CountInvalidTransitions
42
+ def invalid(*args, **kwargs)
43
+ $invalid_transitions += 1
44
+ super
45
+ end
46
+
47
+ def invalid!(*args, **kwargs)
48
+ $invalid_transitions += 1
49
+ super
50
+ end
51
+ end
52
+ Plumb::Result.prepend(CountInvalidTransitions)
53
+
54
+ module Bench
55
+ include Plumb::Types
56
+
57
+ # A value union: on a miss each branch interpolates "Must be equal to #{value}"
58
+ # (ValueClass#call), so late matches allocate error strings too.
59
+ Role = Value['admin'] | Value['editor'] | Value['viewer']
60
+ # email OR nil
61
+ Contact = String[/@/] | Nil
62
+
63
+ class Record < Data
64
+ attribute :name, String.present # not a union: the baseline field
65
+ attribute :age, Lax::Integer # a union of coercions
66
+ attribute :role, Role
67
+ attribute :contact, Contact
68
+ end
69
+ end
70
+
71
+ N = Integer(ENV.fetch('N', '2000'))
72
+
73
+ REGIMES = {
74
+ 'first-branch valid' => { name: 'Joe', age: 40, role: 'admin', contact: 'joe@example.com' },
75
+ 'union-hit valid' => { name: 'Joe', age: '40', role: 'viewer', contact: nil },
76
+ 'fully invalid' => { name: '', age: 'xx', role: 'nope', contact: 123 }
77
+ }.freeze
78
+
79
+ REPORTED_CLASSES = %w[
80
+ Plumb::Result
81
+ String
82
+ Array
83
+ Hash
84
+ ].freeze
85
+
86
+ def run_regime(row)
87
+ # warm (fill caches, JIT) and confirm validity classification
88
+ valid = Bench::Record.resolve(row).valid?
89
+
90
+ $invalid_transitions = 0
91
+ report = MemoryProfiler.report { N.times { Bench::Record.resolve(row) } }
92
+ invalid = $invalid_transitions
93
+
94
+ by_class = report.allocated_objects_by_class.each_with_object(Hash.new(0)) do |h, acc|
95
+ acc[h[:data]] = h[:count]
96
+ end
97
+ total = report.total_allocated
98
+
99
+ { valid:, invalid:, by_class:, total: }
100
+ end
101
+
102
+ results = REGIMES.transform_values { |row| run_regime(row) }
103
+
104
+ puts "Parsing #{N} records per regime. Cells are total objects (per-record).\n\n"
105
+
106
+ LABEL_W = 20
107
+ COL_W = 22
108
+ cell = ->(str) { str.to_s.rjust(COL_W) }
109
+ count = ->(n) { cell.call(format('%d (%.2f)', n, n.to_f / N)) }
110
+
111
+ def rule(width) = puts('-' * width)
112
+
113
+ header = 'regime'.ljust(LABEL_W) + results.keys.map { |k| cell.call(k) }.join
114
+ puts header
115
+ puts 'valid output?'.ljust(LABEL_W) + results.values.map { |r| cell.call(r[:valid]) }.join
116
+ rule(header.size)
117
+
118
+ # Headline: flips to invalid (exact count, not sampled).
119
+ puts 'invalid flips'.ljust(LABEL_W) + results.values.map { |r| count.call(r[:invalid]) }.join
120
+
121
+ # Allocations, from the sampling profiler.
122
+ REPORTED_CLASSES.each do |klass|
123
+ puts klass.sub('Plumb::', '').ljust(LABEL_W) +
124
+ results.values.map { |r| count.call(r[:by_class][klass]) }.join
125
+ end
126
+
127
+ rule(header.size)
128
+ puts 'TOTAL objects'.ljust(LABEL_W) + results.values.map { |r| count.call(r[:total]) }.join
129
+
130
+ # Order sensitivity: the SAME union, matching the first vs the last branch.
131
+ puts "\nOrder sensitivity — (Value[a] | Value[b] | Value[c]).resolve(x), #{N}x each:"
132
+ [%w[admin first], %w[viewer last]].each do |value, position|
133
+ $invalid_transitions = 0
134
+ N.times { Bench::Role.resolve(value) }
135
+ puts format(' match %-5s branch (%-6s): %d invalid flips (%.2f/record)',
136
+ position, value, $invalid_transitions, $invalid_transitions.to_f / N)
137
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared sample payload for the schema-library comparisons (Parametric, Plumb,
4
+ # Dry::Types). A realistic nested "broadband/TV/phone bundle" record.
5
+ SAMPLE_DATA = {
6
+ supplier_name: 'Vodafone',
7
+ start_date: '2020-01-01',
8
+ end_date: '2021-01-11',
9
+ countdown_date: '2021-01-11',
10
+ name: 'Vodafone TV',
11
+ upfront_cost_description: 'Upfront cost description',
12
+ tv_channels_count: 100,
13
+ terms: [
14
+ { name: 'Foo', url: 'http://foo.com', terms_text: 'Foo terms', start_date: '2020-01-01', end_date: '2021-01-01' },
15
+ { name: 'Foo2', url: 'http://foo2.com', terms_text: 'Foo terms', start_date: '2020-01-01', end_date: '2021-01-01' }
16
+ ],
17
+ tv_included: true,
18
+ additional_info: 'Additional info',
19
+ product_type: 'TV',
20
+ annual_price_increase_applies: true,
21
+ annual_price_increase_description: 'Annual price increase description',
22
+ broadband_components: [
23
+ {
24
+ name: 'Broadband 1',
25
+ technology: 'FTTP',
26
+ technology_tags: ['FTTP'],
27
+ is_mobile: false,
28
+ description: 'Broadband 1 description',
29
+ download_speed_measurement: 'Mbps',
30
+ download_speed: 100,
31
+ upload_speed_measurement: 'Mbps',
32
+ upload_speed: 100,
33
+ download_usage_limit: 1000,
34
+ discount_price: 100,
35
+ discount_period: 12,
36
+ speed_description: 'Speed description',
37
+ ongoing_price: 100,
38
+ contract_length: 12,
39
+ upfront_cost: 100,
40
+ commission: 100
41
+ }
42
+ ],
43
+ tv_components: [
44
+ {
45
+ slug: 'vodafone-tv',
46
+ name: 'Vodafone TV',
47
+ search_tags: %w[Vodafone TV],
48
+ description: 'Vodafone TV description',
49
+ channels: 100,
50
+ discount_price: 100
51
+ }
52
+ ],
53
+ call_package_types: ['Everything'],
54
+ phone_components: [
55
+ {
56
+ name: 'Phone 1',
57
+ description: 'Phone 1 description',
58
+ discount_price: 100,
59
+ discount_period: 12,
60
+ ongoing_price: 100,
61
+ contract_length: 12,
62
+ upfront_cost: 100,
63
+ commission: 100,
64
+ call_package_types: ['Everything']
65
+ }
66
+ ],
67
+ payment_methods: ['Credit Card', 'Paypal'],
68
+ discounts: [
69
+ { period: 12, price: 100 }
70
+ ],
71
+ ongoing_price: 100,
72
+ contract_length: 12,
73
+ upfront_cost: 100,
74
+ year_1_price: 100,
75
+ savings: 100,
76
+ commission: 100,
77
+ max_broadband_download_speed: 100
78
+ }.freeze
@@ -65,7 +65,7 @@ module Types
65
65
  FileUtils.mkdir_p(dir)
66
66
  end
67
67
 
68
- # The Plumb::Step interface to make these objects composable.
68
+ # The Plumb::Callable interface to make these objects composable.
69
69
  # @param result [Plumb::Result::Valid]
70
70
  # @return [Plumb::Result::Valid, Plumb::Result::Invalid]
71
71
  def call(result)
@@ -20,10 +20,10 @@ module Types
20
20
  # It implements the #call(Result) => Result interface.
21
21
  # required by all Plumb steps.
22
22
  # URI => Image
23
- Download = Plumb::Step.new do |result|
23
+ Download = Plumb::Composable.wrap(lambda do |result|
24
24
  io = ::URI.open(result.value)
25
25
  result.valid(Image.new(result.value.to_s, io))
26
- end
26
+ end)
27
27
 
28
28
  # A configurable file-system cache to read and write files from.
29
29
  class Cache
@@ -32,12 +32,13 @@ module Types
32
32
  FileUtils.mkdir_p(dir)
33
33
  end
34
34
 
35
- # Wrap the #reader and #wruter methods into Plumb steps
35
+ # Wrap the #reader and #writer methods into Plumb steps
36
36
  # A step only needs #call(Result) => Result to work in a pipeline,
37
- # but wrapping it in Plumb::Step provides the #>> and #| methods for composability,
38
- # as well as all the other helper methods provided by the Composable module.
39
- def read = Plumb::Step.new(method(:reader))
40
- def write = Plumb::Step.new(method(:writer))
37
+ # but wrapping it with Plumb::Composable.wrap provides the #>> and #| methods
38
+ # for composability, as well as all the other helper methods provided by the
39
+ # Composable module.
40
+ def read = Plumb::Composable.wrap(method(:reader))
41
+ def write = Plumb::Composable.wrap(method(:writer))
41
42
 
42
43
  private
43
44
 
@@ -54,7 +55,9 @@ module Types
54
55
  image = result.value
55
56
  path = path_for(image.url)
56
57
  File.open(path, 'wb') { |f| f.write(image.io.read) }
57
- result.valid image.where(url: path, io: File.new(path))
58
+ # `#with` the copy-with-changes method on a Ruby ::Data. (Not Plumb's
59
+ # `#where`, which builds a refined TYPE; `image` here is a Data instance.)
60
+ result.valid image.with(url: path, io: File.new(path))
58
61
  end
59
62
 
60
63
  def path_for(url)
@@ -78,7 +81,11 @@ cache = Types::Cache.new('./examples/data/downloads')
78
81
  # 1). Take a valid URL string.
79
82
  # 2). Attempt reading the file from the cache. Return that if it exists.
80
83
  # 3). Otherwise, download the file from the internet and write it to the cache.
81
- IdempotentDownload = Types::Forms::URI::HTTP >> (cache.read | (Types::Download >> cache.write))
84
+ # The leading step turns a URL string into a URI::HTTP (HTTPS included it is a
85
+ # subclass). The encoder composes here in its decode direction
86
+ # (String -> URI::HTTP), picked automatically from what the rest of the pipeline
87
+ # consumes.
88
+ IdempotentDownload = Plumb::Codec::Forms::HTTPURIEncoder >> (cache.read | (Types::Download >> cache.write))
82
89
 
83
90
  # An array of downloadable images,
84
91
  # marked as concurrent so that all IO operations are run in threads.
@@ -14,6 +14,11 @@ 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
+ # An already-parsed Time, or an ISO8601 string coerced into one. Use
18
+ # `Plumb::Codec::Forms::TimeEncoder` instead if you also want to encode back to
19
+ # a string; ISOTime above covers the one direction this example needs.
20
+ Timestamp = Time | ISOTime
21
+
17
22
  # A UUID string, or generate a new one
18
23
  AutoUUID = UUID::V4.default { SecureRandom.uuid }
19
24
  end
@@ -57,7 +62,7 @@ class Event < Types::Data
57
62
  attribute :id, Types::AutoUUID
58
63
  attribute :stream_id, Types::String.present
59
64
  attribute :type, Types::String
60
- attribute(:created_at, Types::Forms::Time.default { ::Time.now })
65
+ attribute(:created_at, Types::Timestamp.default { ::Time.now })
61
66
  attribute? :causation_id, Types::UUID::V4
62
67
  attribute? :correlation_id, Types::UUID::V4
63
68
  attribute :payload, Types::Static[nil]
data/examples/weekdays.rb CHANGED
@@ -41,7 +41,7 @@ module Types
41
41
  # Ex. [1, 2, 3, 4, 5, 6, 7], [1, 2, 4], ['monday', 'tuesday', 'wednesday', 7]
42
42
  # Turn day names into numbers, and sort the array.
43
43
  Week = Array[DayNameOrNumber]
44
- .policy(size: 1..7)
44
+ .where(size: 1..7)
45
45
  .check('repeated days') { |days| days.uniq.size == days.size }
46
46
  .transform(::Array, &:sort)
47
47
  end
data/lib/plumb/and.rb CHANGED
@@ -1,26 +1,83 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'plumb/composable'
4
+ require 'plumb/conjunction'
4
5
 
5
6
  module Plumb
7
+ # SEQUENTIAL COMPOSITION — a morphism `left.source -> right.target`, built by
8
+ # `Composable#>>` when some side changes the value. The pure-refinement case
9
+ # (neither side converts) is {Plumb::Intersection} instead; see
10
+ # {Plumb::Conjunction} for why the two must be different nodes.
11
+ #
12
+ # #input_type / #output_type are what the chain as a whole CONSUMES and PRODUCES,
13
+ # not its two sides (those are `children`): `(String -> Integer) >> (Integer ->
14
+ # Integer)` consumes String and produces Integer. One hop at construction suffices,
15
+ # since the children are already resolved by the same rule — O(1) per node, and off
16
+ # the memoization path entirely.
6
17
  class And
7
18
  include Composable
8
-
9
- attr_reader :children
19
+ include Conjunction
10
20
 
11
21
  def initialize(left, right)
12
22
  @left = left
13
23
  @right = right
24
+ @input_type = left.input_type
25
+ # A value-preserving right NARROWS what the left produces rather than replacing
26
+ # it, so the chain produces the MEET of the two: `(String -> Integer)` then
27
+ # `where(size: 10)` produces an Integer of size 10, not the bare `(size === 10)`.
28
+ # A converting right replaces the value, so its own output stands.
29
+ #
30
+ # Conjunction.build, not Intersection.new — `left.output_type` need not preserve
31
+ # values (a record drops undeclared keys, a Static replaces it), and only the
32
+ # classifier may decide. The `lo.equal?(left)` guard is the fixpoint: a left that
33
+ # IS its own output type would otherwise recurse forever.
34
+ @output_type = if Plumb::Subtyping.value_preserving?(right)
35
+ lo = left.output_type
36
+ lo.equal?(left) ? self : Conjunction.build(lo, right)
37
+ else
38
+ right.output_type
39
+ end
14
40
  @children = [left, right].freeze
15
41
  freeze
16
42
  end
17
43
 
18
- private def _inspect
19
- %((#{@left.inspect} >> #{@right.inspect}))
44
+ # Identified for subtyping by what it PRODUCES, like Function and Implementation:
45
+ # the input constraints do not carry through. This is what stops the meet rule
46
+ # reaching a chain that converts. @see Composable#subtype_identity
47
+ def subtype_identity = @output_type
48
+
49
+ # Deliberately NO #accepted_type override: a composition accepts what it consumes,
50
+ # its #input_type, which is the default. Accepting against the right child's output
51
+ # (what Intersection does) would demand the upstream already produce what this
52
+ # chain's LAST step emits.
53
+
54
+ # Fuse `self >> other` by RE-ASSOCIATING: `>>` is associative, so when the tail can
55
+ # absorb `other` we rebuild around the fused tail. Without this, one non-fusable
56
+ # step at the head blocks every later step from reducing — a pipeline behind a type
57
+ # gate is `And(gate, step1)`, and only Function-to-Function fuses, so `step2`
58
+ # onwards could never join.
59
+ #
60
+ # The soundness proof stays with the node that owns it: this only re-associates,
61
+ # and `@right.fuse_with(other)` carries its own boundary check. Nil when the tail
62
+ # declines. Recursion is bounded by the chain's depth.
63
+ def fuse_with(other)
64
+ fused = @right.fuse_with(other)
65
+ fused && Conjunction.build(@left, fused)
66
+ end
67
+
68
+ # Boundary absorption re-associates for the same reason #fuse_with does: the
69
+ # boundary a chain presents to a neighbour belongs to the step at that END of it,
70
+ # so `And(gate, f) >> Types::Float` reaches `f`'s output slot, and `Types::Integer
71
+ # >> And(f, x)` reaches `f`'s input slot. Nil when that end declines, and the
72
+ # rebuilt half carries its own soundness proof. @see Composable#absorb_output
73
+ def absorb_output(type)
74
+ absorbed = @right.absorb_output(type)
75
+ absorbed && Conjunction.build(@left, absorbed)
20
76
  end
21
77
 
22
- def call(result)
23
- result.map(@left).map(@right)
78
+ def absorb_input(type)
79
+ absorbed = @left.absorb_input(type)
80
+ absorbed && Conjunction.build(absorbed, @right)
24
81
  end
25
82
  end
26
83
  end
@@ -6,8 +6,15 @@ module Plumb
6
6
  class AnyClass
7
7
  include Composable
8
8
 
9
- def |(other) = Composable.wrap(other)
10
- def >>(other) = Composable.wrap(other)
9
+ # These shortcuts bypass Composable's operators, but still resolve the
10
+ # operand through the #to_plumb_type hook (an Encoder gets no orientation
11
+ # context from the Any top and falls back to its default direction; a
12
+ # Codec must not leak into the tree as a bare node).
13
+ def |(other) = Composable.resolve_operand(other, op: :|, left: self)
14
+ def >>(other) = Composable.resolve_operand(other, op: :>>, left: self)
15
+
16
+ # Top is the identity of intersection: `Any & X == X`, mirroring `Any | X`.
17
+ def &(other) = Composable.resolve_operand(other, op: :&, left: self)
11
18
 
12
19
  # Any.default(value) must trigger default when value is Undefined
13
20
  def default(...)
@@ -15,5 +22,8 @@ module Plumb
15
22
  end
16
23
 
17
24
  def call(result) = result
25
+
26
+ # The identity type — accepts anything, changes nothing.
27
+ def value_preserving? = true
18
28
  end
19
29
  end
@@ -8,6 +8,7 @@ require 'plumb/stream_class'
8
8
  module Plumb
9
9
  class ArrayClass
10
10
  include Composable
11
+ include CovariantFusion
11
12
 
12
13
  attr_reader :children
13
14
 
@@ -24,50 +25,78 @@ module Plumb
24
25
 
25
26
  alias [] of
26
27
 
28
+ # An Array re-maps each element through its element type, so it preserves the
29
+ # value only when that element type does (a coercing element would change the
30
+ # array). This lets `#>>` drop a redundant `Array[Integer] >> Array[Numeric]`
31
+ # and `#|` absorb `Array[Integer] | Array[Numeric]`, matching the scalar case.
32
+ def value_preserving? = children.all? { |c| Plumb::Subtyping.value_preserving?(c) }
33
+
34
+ # Rebuild around new children (see Plumb::Subtyping.map_children).
35
+ def with_children(children) = of(children.first)
36
+
37
+ # As a consumer, an Array accepts elements relaxed to what the ELEMENT type
38
+ # accepts — so `Array[Integer] >> Array[Integer.build(Money)]` composes,
39
+ # mirroring HashClass#accepted_type's per-field relaxation.
40
+ def accepted_type = Plumb::Subtyping.map_children(self) { |c| Plumb::Subtyping.accepted_type(c) }
41
+
42
+ # The value you GET after re-mapping each element: the element type resolved
43
+ # to what it produces, so `Array[String >> Integer].output_type` is
44
+ # `Array[Integer]` (mirror of #accepted_type).
45
+ def output_type = Plumb::Subtyping.map_children(self) { |c| Plumb::Subtyping.resolved_output(c) }
46
+
27
47
  def concurrent
28
- ConcurrentArrayClass.new(element_type:)
48
+ concurrent_class.new(element_type:)
29
49
  end
30
50
 
31
51
  def stream
32
52
  StreamClass.new(element_type:)
33
53
  end
34
54
 
55
+ # A lenient version of this Array: it accepts any Array and emits one with only
56
+ # the valid elements, dropping the rest. @see FilteredArray
35
57
  def filtered
36
- MatchClass.new(::Array) >> Step.new(nil, "Array[#{element_type}].filtered") do |result|
37
- arr = result.value.each.with_object([]) do |e, memo|
38
- r = element_type.resolve(e)
39
- memo << r.value if r.valid?
40
- end
41
- result.valid(arr)
42
- end
58
+ filtered_class.new(element_type:)
43
59
  end
44
60
 
45
61
  def call(result)
46
- return result.invalid(errors: 'is not an Array') unless ::Array === result.value
62
+ return result.invalid!(errors: 'is not an Array') unless ::Array === result.value
47
63
 
64
+ # map_array_elements uses its own element scratch (below), so `result` is
65
+ # untouched until here — flip it in place rather than allocating a fresh one.
48
66
  values, errors = map_array_elements(result)
49
- return result.valid(values) unless errors.any?
67
+ return result.valid!(values) unless errors.any?
50
68
 
51
- result.invalid(values, errors:)
69
+ result.invalid!(values, errors:)
52
70
  end
53
71
 
54
72
  private
55
73
 
56
74
  attr_reader :element_type
57
75
 
76
+ # Named, not hardcoded, so the two combine in EITHER order: each subclass points
77
+ # at the variant that keeps what it already is, instead of the second call
78
+ # dropping the first.
79
+ def concurrent_class = ConcurrentArrayClass
80
+ def filtered_class = FilteredArray
81
+
58
82
  def _inspect
59
83
  %(Array[#{element_type}])
60
84
  end
61
85
 
62
86
  def map_array_elements(result)
63
- # Reuse the same result object for each element
64
- # to decrease object allocation.
65
- # Steps might return the same result instance, so we map the values directly
66
- # separate from the errors.
67
- element_result = result.dup
68
- errors = Hash.new(capacity: result.value.size)
69
- values = result.value.map.with_index do |e, idx|
70
- re = element_type.call(element_result.reset(e))
87
+ # Reuse the INCOMING cursor as the per-element scratch (capturing the array
88
+ # first): each element flips it in place via #reset, and #call flips it a
89
+ # final time to the collected values so a sequential Array validates with
90
+ # zero Result allocations of its own. Steps may return the same result
91
+ # instance, so map values/errors out immediately rather than holding `re`.
92
+ #
93
+ # Element types that produce a value which is lazily consumed later (a
94
+ # Stream) must not close over this reused cursor — StreamClass#call
95
+ # snapshots its source for exactly this reason (see there).
96
+ array = result.value
97
+ errors = Hash.new(capacity: array.size)
98
+ values = array.map.with_index do |e, idx|
99
+ re = element_type.call(result.reset(e))
71
100
  errors[idx] = re.errors unless re.valid?
72
101
  re.value
73
102
  end
@@ -78,19 +107,98 @@ module Plumb
78
107
  class ConcurrentArrayClass < self
79
108
  private
80
109
 
110
+ # Same contract as the sequential version above — collect each element's value,
111
+ # and its errors when it is invalid. Two ways this diverged from it:
112
+ #
113
+ # - an element that resolved to an INVALID Result recorded nothing, because
114
+ # only `f.reason` (an exception) was collected. With no errors recorded,
115
+ # #call reports the whole array VALID, so
116
+ # `Array[Integer].concurrent.resolve(['x'])` came back valid — a concurrent
117
+ # Array was not really validating its elements.
118
+ # - a REJECTED future (the element step raised) has a nil #value, so reading
119
+ # `#value` on it raised `NoMethodError: undefined method 'value' for nil`
120
+ # instead of surfacing the cause. The sequential path lets such an exception
121
+ # propagate, so re-raise the reason to match it.
122
+ #
123
+ # No cursor reuse here, unlike the sequential path: the futures resolve on
124
+ # different threads, so each needs its own Result.
81
125
  def map_array_elements(result)
82
- errors = {}
126
+ array = result.value
127
+ errors = Hash.new(capacity: array.size)
128
+ futures = array.map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
129
+
130
+ values = futures.map.with_index do |future, idx|
131
+ re = future.value # blocks until settled; nil when rejected
132
+ raise future.reason if future.rejected?
83
133
 
84
- values = result.value
85
- .map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
86
- .map.with_index do |f, idx|
87
- re = f.value
88
- errors[idx] = f.reason if f.rejected?
134
+ errors[idx] = re.errors unless re.valid?
89
135
  re.value
90
136
  end
91
137
 
92
138
  [values, errors]
93
139
  end
140
+
141
+ def filtered_class = ConcurrentFilteredArray
142
+ end
143
+
144
+ # Same element type as an ArrayClass, but drops invalid elements instead of
145
+ # rejecting the whole Array. Being an ArrayClass is what keeps it rewritable: a
146
+ # visitor — or a Codec — that maps the element type rebuilds a FilteredArray, not
147
+ # a plain one. @see HashMap::FilteredHashMap, the same arrangement for maps.
148
+ class FilteredArray < self
149
+ # It drops elements, so it changes the value whatever its element type does.
150
+ def value_preserving? = false
151
+
152
+ # Lenient: it never rejects an Array, so as a #>> consumer it accepts any
153
+ # enumerable — without this `Types::Array >> Array[String].filtered` is an
154
+ # illegal narrowing.
155
+ def accepted_type = Types::Each
156
+
157
+ # `Array[T].filtered.stream` IS `Array[T].stream.filtered` — a filtered Stream
158
+ # drops the same elements, lazily.
159
+ def stream = super.filtered
160
+
161
+ def call(result)
162
+ return result.invalid!(errors: 'is not an Array') unless ::Array === result.value
163
+
164
+ result.valid!(valid_elements(result.value))
165
+ end
166
+
167
+ private
168
+
169
+ def concurrent_class = ConcurrentFilteredArray
170
+
171
+ # The only thing the concurrent variant replaces. No errors to collect, unlike
172
+ # #map_array_elements — an invalid element is simply not there.
173
+ def valid_elements(array)
174
+ array.each.with_object([]) do |e, memo|
175
+ r = element_type.resolve(e)
176
+ memo << r.value if r.valid?
177
+ end
178
+ end
179
+
180
+ def _inspect = "Array[#{element_type}].filtered"
181
+ end
182
+
183
+ # Both at once, reached from either side (`.filtered.concurrent`,
184
+ # `.concurrent.filtered`), and itself under both.
185
+ class ConcurrentFilteredArray < FilteredArray
186
+ private
187
+
188
+ def filtered_class = ConcurrentFilteredArray
189
+
190
+ # A rejected future re-raises, as ConcurrentArrayClass does: an element step
191
+ # that RAISED is a bug to surface, not an element to drop.
192
+ def valid_elements(array)
193
+ futures = array.map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
194
+
195
+ futures.each_with_object([]) do |future, memo|
196
+ r = future.value # blocks until settled; nil when rejected
197
+ raise future.reason if future.rejected?
198
+
199
+ memo << r.value if r.valid?
200
+ end
201
+ end
94
202
  end
95
203
  end
96
204
  end