plumb 0.0.17 → 0.2.0.beta.1

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 +834 -63
  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 +64 -19
  19. data/lib/plumb/attribute_value_match.rb +41 -1
  20. data/lib/plumb/attributes.rb +62 -20
  21. data/lib/plumb/codec.rb +795 -0
  22. data/lib/plumb/composable.rb +463 -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 +350 -38
  32. data/lib/plumb/hash_map.rb +62 -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 +25 -23
  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 +32 -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 +27 -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,6 +25,25 @@ 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
48
  ConcurrentArrayClass.new(element_type:)
29
49
  end
@@ -33,22 +53,25 @@ module Plumb
33
53
  end
34
54
 
35
55
  def filtered
36
- MatchClass.new(::Array) >> Step.new(nil, "Array[#{element_type}].filtered") do |result|
56
+ Constraint.new(::Array) >> Function.opaque(inspect: "Array[#{element_type}].filtered",
57
+ identity: [:filtered_array, element_type]) do |result|
37
58
  arr = result.value.each.with_object([]) do |e, memo|
38
59
  r = element_type.resolve(e)
39
60
  memo << r.value if r.valid?
40
61
  end
41
- result.valid(arr)
62
+ result.valid!(arr)
42
63
  end
43
64
  end
44
65
 
45
66
  def call(result)
46
- return result.invalid(errors: 'is not an Array') unless ::Array === result.value
67
+ return result.invalid!(errors: 'is not an Array') unless ::Array === result.value
47
68
 
69
+ # map_array_elements uses its own element scratch (below), so `result` is
70
+ # untouched until here — flip it in place rather than allocating a fresh one.
48
71
  values, errors = map_array_elements(result)
49
- return result.valid(values) unless errors.any?
72
+ return result.valid!(values) unless errors.any?
50
73
 
51
- result.invalid(values, errors:)
74
+ result.invalid!(values, errors:)
52
75
  end
53
76
 
54
77
  private
@@ -60,14 +83,19 @@ module Plumb
60
83
  end
61
84
 
62
85
  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 = {}
69
- values = result.value.map.with_index do |e, idx|
70
- re = element_type.call(element_result.reset(e))
86
+ # Reuse the INCOMING cursor as the per-element scratch (capturing the array
87
+ # first): each element flips it in place via #reset, and #call flips it a
88
+ # final time to the collected values so a sequential Array validates with
89
+ # zero Result allocations of its own. Steps may return the same result
90
+ # instance, so map values/errors out immediately rather than holding `re`.
91
+ #
92
+ # Element types that produce a value which is lazily consumed later (a
93
+ # Stream) must not close over this reused cursor — StreamClass#call
94
+ # snapshots its source for exactly this reason (see there).
95
+ array = result.value
96
+ errors = Hash.new(capacity: array.size)
97
+ values = array.map.with_index do |e, idx|
98
+ re = element_type.call(result.reset(e))
71
99
  errors[idx] = re.errors unless re.valid?
72
100
  re.value
73
101
  end
@@ -78,14 +106,31 @@ module Plumb
78
106
  class ConcurrentArrayClass < self
79
107
  private
80
108
 
109
+ # Same contract as the sequential version above — collect each element's value,
110
+ # and its errors when it is invalid. Two ways this diverged from it:
111
+ #
112
+ # - an element that resolved to an INVALID Result recorded nothing, because
113
+ # only `f.reason` (an exception) was collected. With no errors recorded,
114
+ # #call reports the whole array VALID, so
115
+ # `Array[Integer].concurrent.resolve(['x'])` came back valid — a concurrent
116
+ # Array was not really validating its elements.
117
+ # - a REJECTED future (the element step raised) has a nil #value, so reading
118
+ # `#value` on it raised `NoMethodError: undefined method 'value' for nil`
119
+ # instead of surfacing the cause. The sequential path lets such an exception
120
+ # propagate, so re-raise the reason to match it.
121
+ #
122
+ # No cursor reuse here, unlike the sequential path: the futures resolve on
123
+ # different threads, so each needs its own Result.
81
124
  def map_array_elements(result)
82
- errors = {}
125
+ array = result.value
126
+ errors = Hash.new(capacity: array.size)
127
+ futures = array.map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
128
+
129
+ values = futures.map.with_index do |future, idx|
130
+ re = future.value # blocks until settled; nil when rejected
131
+ raise future.reason if future.rejected?
83
132
 
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?
133
+ errors[idx] = re.errors unless re.valid?
89
134
  re.value
90
135
  end
91
136
 
@@ -7,6 +7,10 @@ module Plumb
7
7
  attr_reader :type, :attr_name, :value
8
8
 
9
9
  def initialize(type, attr_name, value)
10
+ unless attr_name.is_a?(::Symbol) || attr_name.is_a?(::String)
11
+ raise ArgumentError, "attribute name must be a Symbol or String, got #{attr_name.inspect}"
12
+ end
13
+
10
14
  @type = type
11
15
  @attr_name = attr_name
12
16
  @value = value
@@ -15,12 +19,48 @@ module Plumb
15
19
  freeze
16
20
  end
17
21
 
22
+ # Two attribute constraints are equal when they constrain the same attribute
23
+ # of the same base type to the same value. (The default Composable#== only
24
+ # compares #children, which an AttributeValueMatch doesn't expose, so it
25
+ # would treat every constraint as equal.)
26
+ def ==(other)
27
+ other.is_a?(self.class) &&
28
+ attr_name == other.attr_name &&
29
+ value == other.value &&
30
+ type == other.type
31
+ end
32
+
18
33
  def metadata = type.metadata
19
34
 
35
+ # An attribute-value constraint narrows by value, so it accepts any input
36
+ # (opts out of #>> composition type-checks).
37
+ def input_type = Types::Any
38
+
39
+ # It checks an attribute and returns the value unchanged — a pure refinement.
40
+ def value_preserving? = true
41
+
42
+ # Subtyping for an attribute constraint. Against another constraint on the
43
+ # SAME attribute, the base types must be compatible and this constraint's
44
+ # value must be a subtype of the other's — eg. `size: 10` <= `size: 8..100`,
45
+ # but `size: 10..15` </= `size: 11..14`. Against any other type, an
46
+ # attribute-constrained type is simply a subtype of its base type (eg.
47
+ # `Array.where(size: 10) <= Array`).
48
+ def subtype_of?(other)
49
+ if other.is_a?(AttributeValueMatch)
50
+ attr_name == other.attr_name &&
51
+ Plumb::Subtyping.subtype?(type, other.type) &&
52
+ Plumb::Subtyping.value_subtype?(value, other.value)
53
+ else
54
+ Plumb::Subtyping.subtype?(type, other)
55
+ end
56
+ end
57
+
20
58
  def call(result)
59
+ # Missing attributes fail the constraint instead of raising.
60
+ return result.invalid!(errors: @error) unless result.value.respond_to?(attr_name)
21
61
  return result if value === result.value.public_send(attr_name)
22
62
 
23
- result.invalid(errors: @error)
63
+ result.invalid!(errors: @error)
24
64
  end
25
65
 
26
66
  private def _inspect = @inspect_line