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.
- checksums.yaml +4 -4
- data/README.md +887 -64
- data/bench/compare_dry_schema.rb +79 -0
- data/bench/compare_dry_types.rb +37 -0
- data/bench/compare_parametric_schema.rb +2 -80
- data/bench/dry_schema_hash.rb +103 -0
- data/bench/dry_types_hash.rb +125 -0
- data/bench/json_schema_profile.rb +107 -0
- data/bench/plumb_hash.rb +17 -11
- data/bench/results_allocations.rb +137 -0
- data/bench/sample_data.rb +78 -0
- data/examples/command_objects.rb +1 -1
- data/examples/concurrent_downloads.rb +16 -9
- data/examples/event_registry.rb +6 -1
- data/examples/weekdays.rb +1 -1
- data/lib/plumb/and.rb +63 -6
- data/lib/plumb/any_class.rb +12 -2
- data/lib/plumb/array_class.rb +133 -25
- data/lib/plumb/attribute_value_match.rb +41 -1
- data/lib/plumb/attributes.rb +59 -19
- data/lib/plumb/codec.rb +886 -0
- data/lib/plumb/composable.rb +451 -39
- data/lib/plumb/conjunction.rb +50 -0
- data/lib/plumb/constraint.rb +234 -0
- data/lib/plumb/covariant_fusion.rb +46 -0
- data/lib/plumb/decorator.rb +12 -22
- data/lib/plumb/deferred.rb +13 -5
- data/lib/plumb/disjunction.rb +112 -0
- data/lib/plumb/encoder.rb +207 -0
- data/lib/plumb/function.rb +347 -0
- data/lib/plumb/hash_class.rb +339 -32
- data/lib/plumb/hash_map.rb +58 -14
- data/lib/plumb/implementation.rb +247 -0
- data/lib/plumb/interface_class.rb +21 -2
- data/lib/plumb/intersection.rb +47 -0
- data/lib/plumb/json_schema_visitor.rb +255 -36
- data/lib/plumb/key.rb +63 -13
- data/lib/plumb/mermaid_visitor.rb +129 -0
- data/lib/plumb/metadata.rb +10 -1
- data/lib/plumb/metadata_visitor.rb +36 -34
- data/lib/plumb/never_class.rb +38 -0
- data/lib/plumb/node_mapper.rb +97 -0
- data/lib/plumb/not.rb +34 -2
- data/lib/plumb/optimizer.rb +444 -0
- data/lib/plumb/or.rb +26 -29
- data/lib/plumb/pipeline.rb +99 -11
- data/lib/plumb/policy.rb +17 -4
- data/lib/plumb/range_class.rb +46 -0
- data/lib/plumb/relation.rb +57 -0
- data/lib/plumb/result.rb +55 -23
- data/lib/plumb/semantic_matcher.rb +393 -0
- data/lib/plumb/static_class.rb +20 -1
- data/lib/plumb/stream_class.rb +28 -6
- data/lib/plumb/subtyping.rb +461 -0
- data/lib/plumb/tagged_hash.rb +45 -4
- data/lib/plumb/tuple_class.rb +21 -4
- data/lib/plumb/type_cache.rb +41 -0
- data/lib/plumb/type_registry.rb +71 -0
- data/lib/plumb/typed_step.rb +67 -0
- data/lib/plumb/types.rb +44 -43
- data/lib/plumb/union.rb +30 -0
- data/lib/plumb/value_class.rb +20 -1
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +20 -4
- data/lib/plumb.rb +90 -3
- metadata +30 -8
- data/lib/plumb/build.rb +0 -22
- data/lib/plumb/match_class.rb +0 -42
- data/lib/plumb/schema.rb +0 -195
- data/lib/plumb/step.rb +0 -27
- 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
|
data/examples/command_objects.rb
CHANGED
|
@@ -65,7 +65,7 @@ module Types
|
|
|
65
65
|
FileUtils.mkdir_p(dir)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
# The Plumb::
|
|
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::
|
|
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 #
|
|
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
|
|
38
|
-
# as well as all the other helper methods provided by the
|
|
39
|
-
|
|
40
|
-
def
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
data/examples/event_registry.rb
CHANGED
|
@@ -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::
|
|
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
|
-
.
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
23
|
-
|
|
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
|
data/lib/plumb/any_class.rb
CHANGED
|
@@ -6,8 +6,15 @@ module Plumb
|
|
|
6
6
|
class AnyClass
|
|
7
7
|
include Composable
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
data/lib/plumb/array_class.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|