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,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Plumb vs Dry::Schema — the apples-to-apples comparison.
|
|
4
|
+
#
|
|
5
|
+
# Unlike Dry::Types (bare values, raises on first error), Dry::Schema shares
|
|
6
|
+
# Plumb's model: validate + coerce, collect ALL errors, return a Result, never
|
|
7
|
+
# raise. It goes one step further and resolves human-readable messages via
|
|
8
|
+
# `errors.to_h`. We measure both the happy path (validate + collect, no errors)
|
|
9
|
+
# and the error path (validate + resolve every message), since message
|
|
10
|
+
# resolution only does real work when there ARE errors.
|
|
11
|
+
|
|
12
|
+
require 'benchmark/ips'
|
|
13
|
+
require 'money'
|
|
14
|
+
::Money.default_currency = ::Money::Currency.new('GBP')
|
|
15
|
+
|
|
16
|
+
$LOAD_PATH.unshift File.expand_path(__dir__)
|
|
17
|
+
require 'sample_data'
|
|
18
|
+
require 'plumb_hash'
|
|
19
|
+
require 'dry_schema_hash'
|
|
20
|
+
|
|
21
|
+
VALID = SAMPLE_DATA
|
|
22
|
+
|
|
23
|
+
# A copy of the payload with ~9 type errors scattered across the tree (top-level,
|
|
24
|
+
# nested-hash, array-element). Chosen so BOTH libraries reject them and NONE hit
|
|
25
|
+
# the Money constructor (which raises rather than returning a clean error).
|
|
26
|
+
INVALID = Marshal.load(Marshal.dump(SAMPLE_DATA)).tap do |d|
|
|
27
|
+
d[:supplier_name] = nil
|
|
28
|
+
d[:name] = ''
|
|
29
|
+
d[:tv_channels_count] = 'abc'
|
|
30
|
+
d[:tv_included] = 'nope'
|
|
31
|
+
d[:terms][0][:name] = 123
|
|
32
|
+
d[:broadband_components][0][:download_speed] = 'fast'
|
|
33
|
+
d[:broadband_components][0][:contract_length] = 'soon'
|
|
34
|
+
d[:discounts][0][:period] = 'ptwelve'
|
|
35
|
+
d[:max_broadband_download_speed] = 'lots'
|
|
36
|
+
end.freeze
|
|
37
|
+
|
|
38
|
+
# ---- sanity ---------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def count_leaves(errors)
|
|
41
|
+
case errors
|
|
42
|
+
when ::Hash then errors.values.sum { |v| count_leaves(v) }
|
|
43
|
+
when ::Array then errors.sum { |v| v.is_a?(::Hash) || v.is_a?(::Array) ? count_leaves(v) : 1 }
|
|
44
|
+
else 1
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
plumb_ok = PlumbHash::Record.resolve(VALID)
|
|
49
|
+
dry_ok = DrySchemaHash::Record.call(VALID)
|
|
50
|
+
plumb_bad = PlumbHash::Record.resolve(INVALID)
|
|
51
|
+
dry_bad = DrySchemaHash::Record.call(INVALID)
|
|
52
|
+
|
|
53
|
+
puts '=== sanity ==='
|
|
54
|
+
puts "Plumb valid data -> valid?: #{plumb_ok.valid?}"
|
|
55
|
+
puts "Dry valid data -> success?: #{dry_ok.success?}"
|
|
56
|
+
puts "Plumb invalid data -> valid?: #{plumb_bad.valid?} (#{count_leaves(plumb_bad.errors)} error leaves)"
|
|
57
|
+
puts "Dry invalid data -> success?: #{dry_bad.success?} (#{count_leaves(dry_bad.errors.to_h)} error leaves)"
|
|
58
|
+
puts "Dry messages: #{dry_bad.errors.to_h}"
|
|
59
|
+
puts "Plumb errors: #{plumb_bad.errors}"
|
|
60
|
+
puts
|
|
61
|
+
|
|
62
|
+
# ---- happy path: validate + collect (no errors) ---------------------------
|
|
63
|
+
|
|
64
|
+
puts '=== happy path: validate + collect errors ==='
|
|
65
|
+
Benchmark.ips do |x|
|
|
66
|
+
x.report('Plumb') { PlumbHash::Record.resolve(VALID).errors }
|
|
67
|
+
x.report('Dry::Schema') { DrySchemaHash::Record.call(VALID).errors.to_h }
|
|
68
|
+
x.compare!
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ---- error path: validate + resolve ALL messages --------------------------
|
|
72
|
+
|
|
73
|
+
puts
|
|
74
|
+
puts '=== error path: validate + resolve all messages ==='
|
|
75
|
+
Benchmark.ips do |x|
|
|
76
|
+
x.report('Plumb') { PlumbHash::Record.resolve(INVALID).errors }
|
|
77
|
+
x.report('Dry::Schema') { DrySchemaHash::Record.call(INVALID).errors.to_h }
|
|
78
|
+
x.compare!
|
|
79
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler'
|
|
4
|
+
Bundler.setup(:benchmark)
|
|
5
|
+
|
|
6
|
+
require 'benchmark/ips'
|
|
7
|
+
require 'money'
|
|
8
|
+
Money.rounding_mode = BigDecimal::ROUND_HALF_EVEN
|
|
9
|
+
Money.default_currency = 'GBP'
|
|
10
|
+
|
|
11
|
+
require_relative './sample_data'
|
|
12
|
+
require_relative './plumb_hash'
|
|
13
|
+
require_relative './dry_types_hash'
|
|
14
|
+
|
|
15
|
+
data = SAMPLE_DATA
|
|
16
|
+
|
|
17
|
+
# Sanity-check that both do equivalent (successful) work before timing, so we're
|
|
18
|
+
# not comparing a happy path against an error path.
|
|
19
|
+
plumb_result = PlumbHash::Record.resolve(data)
|
|
20
|
+
warn "Plumb valid?: #{plumb_result.valid?}"
|
|
21
|
+
warn "Plumb errors: #{plumb_result.errors.inspect}" unless plumb_result.valid?
|
|
22
|
+
begin
|
|
23
|
+
DryTypesHash::Record.call(data)
|
|
24
|
+
warn 'Dry::Types valid?: true'
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
warn "Dry::Types FAILED: #{e.class}: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Benchmark.ips do |x|
|
|
30
|
+
x.report('Plumb') do
|
|
31
|
+
PlumbHash::Record.resolve(data)
|
|
32
|
+
end
|
|
33
|
+
x.report('Dry::Types') do
|
|
34
|
+
DryTypesHash::Record.call(data)
|
|
35
|
+
end
|
|
36
|
+
x.compare!
|
|
37
|
+
end
|
|
@@ -7,90 +7,12 @@ require 'benchmark/ips'
|
|
|
7
7
|
require 'money'
|
|
8
8
|
Money.rounding_mode = BigDecimal::ROUND_HALF_EVEN
|
|
9
9
|
Money.default_currency = 'GBP'
|
|
10
|
+
require_relative './sample_data'
|
|
10
11
|
require_relative './parametric_schema'
|
|
11
12
|
require_relative './plumb_hash'
|
|
12
13
|
|
|
13
|
-
data =
|
|
14
|
-
supplier_name: 'Vodafone',
|
|
15
|
-
start_date: '2020-01-01',
|
|
16
|
-
end_date: '2021-01-11',
|
|
17
|
-
countdown_date: '2021-01-11',
|
|
18
|
-
name: 'Vodafone TV',
|
|
19
|
-
upfront_cost_description: 'Upfront cost description',
|
|
20
|
-
tv_channels_count: 100,
|
|
21
|
-
terms: [
|
|
22
|
-
{ name: 'Foo', url: 'http://foo.com', terms_text: 'Foo terms', start_date: '2020-01-01', end_date: '2021-01-01' },
|
|
23
|
-
{ name: 'Foo2', url: 'http://foo2.com', terms_text: 'Foo terms', start_date: '2020-01-01', end_date: '2021-01-01' }
|
|
24
|
-
],
|
|
25
|
-
tv_included: true,
|
|
26
|
-
additional_info: 'Additional info',
|
|
27
|
-
product_type: 'TV',
|
|
28
|
-
annual_price_increase_applies: true,
|
|
29
|
-
annual_price_increase_description: 'Annual price increase description',
|
|
30
|
-
broadband_components: [
|
|
31
|
-
{
|
|
32
|
-
name: 'Broadband 1',
|
|
33
|
-
technology: 'FTTP',
|
|
34
|
-
technology_tags: ['FTTP'],
|
|
35
|
-
is_mobile: false,
|
|
36
|
-
description: 'Broadband 1 description',
|
|
37
|
-
download_speed_measurement: 'Mbps',
|
|
38
|
-
download_speed: 100,
|
|
39
|
-
upload_speed_measurement: 'Mbps',
|
|
40
|
-
upload_speed: 100,
|
|
41
|
-
download_usage_limit: 1000,
|
|
42
|
-
discount_price: 100,
|
|
43
|
-
discount_period: 12,
|
|
44
|
-
speed_description: 'Speed description',
|
|
45
|
-
ongoing_price: 100,
|
|
46
|
-
contract_length: 12,
|
|
47
|
-
upfront_cost: 100,
|
|
48
|
-
commission: 100
|
|
49
|
-
}
|
|
50
|
-
],
|
|
51
|
-
tv_components: [
|
|
52
|
-
{
|
|
53
|
-
slug: 'vodafone-tv',
|
|
54
|
-
name: 'Vodafone TV',
|
|
55
|
-
search_tags: %w[Vodafone TV],
|
|
56
|
-
description: 'Vodafone TV description',
|
|
57
|
-
channels: 100,
|
|
58
|
-
discount_price: 100
|
|
59
|
-
}
|
|
60
|
-
],
|
|
61
|
-
call_package_types: ['Everything'],
|
|
62
|
-
phone_components: [
|
|
63
|
-
{
|
|
64
|
-
name: 'Phone 1',
|
|
65
|
-
description: 'Phone 1 description',
|
|
66
|
-
discount_price: 100,
|
|
67
|
-
disount_period: 12,
|
|
68
|
-
ongoing_price: 100,
|
|
69
|
-
contract_length: 12,
|
|
70
|
-
upfront_cost: 100,
|
|
71
|
-
commission: 100,
|
|
72
|
-
call_package_types: ['Everything']
|
|
73
|
-
}
|
|
74
|
-
],
|
|
75
|
-
payment_methods: ['Credit Card', 'Paypal'],
|
|
76
|
-
discounts: [
|
|
77
|
-
{ period: 12, price: 100 }
|
|
78
|
-
],
|
|
79
|
-
ongoing_price: 100,
|
|
80
|
-
contract_length: 12,
|
|
81
|
-
upfront_cost: 100,
|
|
82
|
-
year_1_price: 100,
|
|
83
|
-
savings: 100,
|
|
84
|
-
commission: 100,
|
|
85
|
-
max_broadband_download_speed: 100
|
|
86
|
-
}
|
|
14
|
+
data = SAMPLE_DATA
|
|
87
15
|
|
|
88
|
-
# p V1Schemas::RECORD.resolve(data).errors
|
|
89
|
-
# p V2Schemas::Record.resolve(data)
|
|
90
|
-
# result = Parametric::V2::Result.wrap(data)
|
|
91
|
-
|
|
92
|
-
# p result
|
|
93
|
-
# p V2Schema.call(result)
|
|
94
16
|
Benchmark.ips do |x|
|
|
95
17
|
x.report('Parametric::Schema') do
|
|
96
18
|
ParametricSchema::RECORD.resolve(data)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry/schema'
|
|
4
|
+
require 'date'
|
|
5
|
+
require 'money'
|
|
6
|
+
require 'monetize'
|
|
7
|
+
|
|
8
|
+
# Dry::Schema port of PlumbHash::Record (see bench/plumb_hash.rb), for an
|
|
9
|
+
# apples-to-apples comparison: like Plumb (and unlike raw Dry::Types), a
|
|
10
|
+
# Dry::Schema returns a Result carrying ALL errors and never raises — and it
|
|
11
|
+
# additionally resolves human-readable messages via `result.errors.to_h`.
|
|
12
|
+
#
|
|
13
|
+
# Params schema so string dates coerce to Date and the native ints/bools pass
|
|
14
|
+
# through. Plumb `.default(x)` fields are provided by the sample data, so they
|
|
15
|
+
# map to `required`. Numeric maps to :integer (the sample values are integers).
|
|
16
|
+
module DrySchemaHash
|
|
17
|
+
module T
|
|
18
|
+
include Dry.Types()
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Same coercion as PlumbHash::Money — a single constructor, not a union.
|
|
22
|
+
# STUB_MONEY swaps it for an identity passthrough (see plumb_hash.rb).
|
|
23
|
+
MoneyType = if ENV['STUB_MONEY']
|
|
24
|
+
T::Any
|
|
25
|
+
else
|
|
26
|
+
T.Constructor(::Money) do |v|
|
|
27
|
+
v.is_a?(::Money) ? v : Monetize.parse!(v.to_s.gsub(',', ''))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Record = Dry::Schema.Params do
|
|
32
|
+
required(:supplier_name).filled(:string)
|
|
33
|
+
required(:start_date).maybe(:date)
|
|
34
|
+
required(:end_date).maybe(:date)
|
|
35
|
+
required(:countdown_date).maybe(:date)
|
|
36
|
+
required(:name).filled(:string)
|
|
37
|
+
required(:upfront_cost_description).value(:string)
|
|
38
|
+
required(:tv_channels_count).value(:integer)
|
|
39
|
+
required(:terms).array(:hash) do
|
|
40
|
+
required(:name).value(:string)
|
|
41
|
+
required(:url).value(:string)
|
|
42
|
+
required(:terms_text).value(:string)
|
|
43
|
+
optional(:start_date).maybe(:date)
|
|
44
|
+
optional(:end_date).maybe(:date)
|
|
45
|
+
end
|
|
46
|
+
required(:tv_included).value(:bool)
|
|
47
|
+
required(:additional_info).value(:string)
|
|
48
|
+
required(:product_type).maybe(:string)
|
|
49
|
+
required(:annual_price_increase_applies).value(:bool)
|
|
50
|
+
required(:annual_price_increase_description).value(:string)
|
|
51
|
+
required(:broadband_components).array(:hash) do
|
|
52
|
+
required(:name).value(:string)
|
|
53
|
+
required(:technology).value(:string)
|
|
54
|
+
required(:is_mobile).value(:bool)
|
|
55
|
+
required(:description).value(:string)
|
|
56
|
+
required(:technology_tags).array(:string)
|
|
57
|
+
required(:download_speed_measurement).value(:string)
|
|
58
|
+
required(:download_speed).value(:integer)
|
|
59
|
+
required(:upload_speed_measurement).value(:string)
|
|
60
|
+
required(:upload_speed).value(:integer)
|
|
61
|
+
required(:download_usage_limit).maybe(:integer)
|
|
62
|
+
required(:discount_price).maybe(MoneyType)
|
|
63
|
+
required(:discount_period).maybe(:integer)
|
|
64
|
+
required(:speed_description).value(:string)
|
|
65
|
+
required(:ongoing_price).maybe(MoneyType)
|
|
66
|
+
required(:contract_length).maybe(:integer)
|
|
67
|
+
required(:upfront_cost).maybe(MoneyType)
|
|
68
|
+
required(:commission).maybe(MoneyType)
|
|
69
|
+
end
|
|
70
|
+
required(:tv_components).array(:hash) do
|
|
71
|
+
required(:slug).value(:string)
|
|
72
|
+
required(:name).filled(:string)
|
|
73
|
+
required(:search_tags).array(:string)
|
|
74
|
+
required(:description).value(:string)
|
|
75
|
+
required(:channels).value(:integer)
|
|
76
|
+
required(:discount_price).value(MoneyType)
|
|
77
|
+
end
|
|
78
|
+
required(:call_package_types).array(:string)
|
|
79
|
+
required(:phone_components).array(:hash) do
|
|
80
|
+
required(:name).value(:string)
|
|
81
|
+
required(:description).value(:string)
|
|
82
|
+
required(:discount_price).maybe(MoneyType)
|
|
83
|
+
required(:discount_period).maybe(:integer)
|
|
84
|
+
required(:ongoing_price).maybe(MoneyType)
|
|
85
|
+
required(:contract_length).maybe(:integer)
|
|
86
|
+
required(:upfront_cost).maybe(MoneyType)
|
|
87
|
+
required(:commission).maybe(MoneyType)
|
|
88
|
+
optional(:call_package_type).array(:string)
|
|
89
|
+
end
|
|
90
|
+
required(:payment_methods).array(:string)
|
|
91
|
+
required(:discounts).array(:hash) do
|
|
92
|
+
required(:period).value(:integer)
|
|
93
|
+
required(:price).maybe(MoneyType)
|
|
94
|
+
end
|
|
95
|
+
required(:ongoing_price).maybe(MoneyType)
|
|
96
|
+
required(:contract_length).maybe(:integer)
|
|
97
|
+
required(:upfront_cost).maybe(MoneyType)
|
|
98
|
+
required(:year_1_price).maybe(MoneyType)
|
|
99
|
+
required(:savings).maybe(MoneyType)
|
|
100
|
+
required(:commission).maybe(MoneyType)
|
|
101
|
+
required(:max_broadband_download_speed).value(:integer)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry/types'
|
|
4
|
+
require 'date'
|
|
5
|
+
require 'money'
|
|
6
|
+
require 'monetize'
|
|
7
|
+
|
|
8
|
+
# Dry::Types port of PlumbHash::Record (see bench/plumb_hash.rb) — the same
|
|
9
|
+
# nested record with the same coercions/defaults/nullability, built with
|
|
10
|
+
# dry-types hash schemas so the comparison is apples-to-apples.
|
|
11
|
+
module DryTypesHash
|
|
12
|
+
module T
|
|
13
|
+
include Dry.Types()
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
BLANK_ARRAY = [].freeze
|
|
17
|
+
BLANK_STRING = ''
|
|
18
|
+
|
|
19
|
+
# Custom Money type: pass a Money through, else parse via Monetize (mirrors
|
|
20
|
+
# PlumbHash::Money). STUB_MONEY swaps it for an identity passthrough (see
|
|
21
|
+
# plumb_hash.rb) so the benchmark can measure the schema WITHOUT the shared
|
|
22
|
+
# money-parsing cost that both libraries otherwise pay.
|
|
23
|
+
Money = if ENV['STUB_MONEY']
|
|
24
|
+
T::Any
|
|
25
|
+
else
|
|
26
|
+
T.Constructor(::Money) do |value|
|
|
27
|
+
value.is_a?(::Money) ? value : Monetize.parse!(value.to_s.gsub(',', ''))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Blank string -> nil, otherwise coerce to a Date (mirrors Forms::Nil | Forms::Date).
|
|
32
|
+
BlankStringOrDate = T::Params::Date.optional
|
|
33
|
+
|
|
34
|
+
PresentString = T::Strict::String.constrained(min_size: 1)
|
|
35
|
+
Integer0 = T::Coercible::Integer.default(0)
|
|
36
|
+
NullableInteger = T::Coercible::Integer.optional
|
|
37
|
+
NullableMoney = Money.optional
|
|
38
|
+
DefaultString = T::String.default(BLANK_STRING)
|
|
39
|
+
|
|
40
|
+
Term = T::Hash.schema(
|
|
41
|
+
name: DefaultString,
|
|
42
|
+
url: DefaultString,
|
|
43
|
+
terms_text: DefaultString,
|
|
44
|
+
start_date?: BlankStringOrDate,
|
|
45
|
+
end_date?: BlankStringOrDate
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
TvComponent = T::Hash.schema(
|
|
49
|
+
slug: T::String,
|
|
50
|
+
name: PresentString,
|
|
51
|
+
search_tags: T::Array.of(T::String).default(BLANK_ARRAY),
|
|
52
|
+
description: DefaultString,
|
|
53
|
+
channels: Integer0,
|
|
54
|
+
discount_price: Money.default(::Money.zero.freeze)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
BroadbandComponent = T::Hash.schema(
|
|
58
|
+
name: T::String,
|
|
59
|
+
technology: T::String,
|
|
60
|
+
is_mobile: T::Strict::Bool.default(false),
|
|
61
|
+
description: T::String,
|
|
62
|
+
technology_tags: T::Array.of(T::String).default(BLANK_ARRAY),
|
|
63
|
+
download_speed_measurement: DefaultString,
|
|
64
|
+
download_speed: T::Coercible::Float.default(0.0),
|
|
65
|
+
upload_speed_measurement: DefaultString,
|
|
66
|
+
upload_speed: T::Coercible::Float.default(0.0),
|
|
67
|
+
download_usage_limit: NullableInteger,
|
|
68
|
+
discount_price: NullableMoney,
|
|
69
|
+
discount_period: NullableInteger,
|
|
70
|
+
speed_description: DefaultString,
|
|
71
|
+
ongoing_price: NullableMoney,
|
|
72
|
+
contract_length: NullableInteger,
|
|
73
|
+
upfront_cost: NullableMoney,
|
|
74
|
+
commission: NullableMoney
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
PhoneComponent = T::Hash.schema(
|
|
78
|
+
name: T::String,
|
|
79
|
+
description: T::String,
|
|
80
|
+
discount_price: NullableMoney,
|
|
81
|
+
discount_period: NullableInteger,
|
|
82
|
+
ongoing_price: NullableMoney,
|
|
83
|
+
contract_length: NullableInteger,
|
|
84
|
+
upfront_cost: NullableMoney,
|
|
85
|
+
commission: NullableMoney,
|
|
86
|
+
call_package_type: T::Array.of(T::String).default(BLANK_ARRAY)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
Discount = T::Hash.schema(
|
|
90
|
+
period: T::Coercible::Integer,
|
|
91
|
+
price: NullableMoney
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
Record = T::Hash.schema(
|
|
95
|
+
supplier_name: PresentString,
|
|
96
|
+
start_date: BlankStringOrDate,
|
|
97
|
+
end_date: BlankStringOrDate,
|
|
98
|
+
countdown_date: BlankStringOrDate,
|
|
99
|
+
name: PresentString,
|
|
100
|
+
upfront_cost_description: DefaultString,
|
|
101
|
+
tv_channels_count: Integer0,
|
|
102
|
+
# Plumb's `.where(size: 1..).default([])` (default bypasses the size check)
|
|
103
|
+
# has no direct dry equivalent — dry rejects a default that violates the
|
|
104
|
+
# constraint. The data always provides `terms`, so keep the size check.
|
|
105
|
+
terms: T::Array.of(Term).constrained(min_size: 1),
|
|
106
|
+
tv_included: T::Strict::Bool,
|
|
107
|
+
additional_info: T::String,
|
|
108
|
+
product_type: T::String.optional,
|
|
109
|
+
annual_price_increase_applies: T::Strict::Bool.default(false),
|
|
110
|
+
annual_price_increase_description: DefaultString,
|
|
111
|
+
broadband_components: T::Array.of(BroadbandComponent),
|
|
112
|
+
tv_components: T::Array.of(TvComponent).default(BLANK_ARRAY),
|
|
113
|
+
call_package_types: T::Array.of(T::String).default(BLANK_ARRAY),
|
|
114
|
+
phone_components: T::Array.of(PhoneComponent).default(BLANK_ARRAY),
|
|
115
|
+
payment_methods: T::Array.of(T::String).default(BLANK_ARRAY),
|
|
116
|
+
discounts: T::Array.of(Discount),
|
|
117
|
+
ongoing_price: NullableMoney,
|
|
118
|
+
contract_length: NullableInteger,
|
|
119
|
+
upfront_cost: NullableMoney,
|
|
120
|
+
year_1_price: NullableMoney,
|
|
121
|
+
savings: NullableMoney,
|
|
122
|
+
commission: NullableMoney,
|
|
123
|
+
max_broadband_download_speed: Integer0
|
|
124
|
+
)
|
|
125
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Profiles JSON Schema GENERATION (the JSONSchemaVisitor traversal) for a
|
|
4
|
+
# realistic, deeply nested REST API payload — an e-commerce "Order" resource
|
|
5
|
+
# with a customer, addresses, line items, enums, coercing composite types
|
|
6
|
+
# (a Forms-coerced date and boolean, Lax::Integer/Decimal/String) and unions.
|
|
7
|
+
#
|
|
8
|
+
# Compositions are reduced at build time (union absorption/factoring,
|
|
9
|
+
# refinement/where collapse), so the type GRAPH the visitor walks is smaller and
|
|
10
|
+
# the emitted schema is flatter (folded anyOf, factored enums). This measures the
|
|
11
|
+
# resulting generation cost — run it before and after a change to compare.
|
|
12
|
+
#
|
|
13
|
+
# Public API only. NB: there is no Types::Lax::Date; a coercing date/boolean is
|
|
14
|
+
# `real type | the matching Codec::Forms encoder` (see FormsDate / FormsBoolean).
|
|
15
|
+
|
|
16
|
+
require 'plumb'
|
|
17
|
+
require 'json'
|
|
18
|
+
require 'date'
|
|
19
|
+
|
|
20
|
+
ITERS = 20_000
|
|
21
|
+
|
|
22
|
+
module T
|
|
23
|
+
include Plumb::Types
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
EMAIL = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
|
|
27
|
+
SKU = /\A[A-Z0-9\-]+\z/
|
|
28
|
+
REF = /\A[A-Z0-9]{8,}\z/
|
|
29
|
+
|
|
30
|
+
# Coercing scalars: "accept the parsed Ruby value, or decode the stringly form" —
|
|
31
|
+
# the same shape as `Types::Lax::*`, built from the Forms codec's encoders. Both
|
|
32
|
+
# branches matter here: a coercing field is exactly what makes the visitor fold a
|
|
33
|
+
# union into one field spec.
|
|
34
|
+
FORMS = Plumb::Codec::Forms
|
|
35
|
+
FormsDate = T::Date | FORMS::DateEncoder
|
|
36
|
+
FormsBoolean = T::Boolean | FORMS::TrueEncoder | FORMS::FalseEncoder
|
|
37
|
+
|
|
38
|
+
# Enums built as unions of literal String constraints — factoring pulls the shared
|
|
39
|
+
# `String` gate out into one `anyOf`.
|
|
40
|
+
Currency = T::String['USD'] | T::String['EUR'] | T::String['GBP']
|
|
41
|
+
Status = T::String['pending'] | T::String['paid'] | T::String['shipped'] | T::String['cancelled']
|
|
42
|
+
|
|
43
|
+
Address = T::Hash[
|
|
44
|
+
street: T::String.where(size: 1..200),
|
|
45
|
+
city: T::String,
|
|
46
|
+
postal_code: T::Lax::String,
|
|
47
|
+
country_code: T::String.where(size: 2)
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
LineItem = T::Hash[
|
|
51
|
+
sku: T::String[SKU],
|
|
52
|
+
description: (T::String | T::Nil),
|
|
53
|
+
quantity: T::Lax::Integer,
|
|
54
|
+
unit_price: T::Lax::Decimal,
|
|
55
|
+
currency: Currency,
|
|
56
|
+
tags: T::Array[T::String]
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
Customer = T::Hash[
|
|
60
|
+
id: T::Integer,
|
|
61
|
+
name: T::String.where(size: 1..100),
|
|
62
|
+
email: T::String[EMAIL],
|
|
63
|
+
phone: (T::String | T::Nil),
|
|
64
|
+
verified: FormsBoolean,
|
|
65
|
+
loyalty_points: (T::Integer | T::Numeric), # value-preserving union -> absorbs to Numeric
|
|
66
|
+
address: Address,
|
|
67
|
+
billing_address: Address
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
ORDER = T::Hash[
|
|
71
|
+
id: T::Integer,
|
|
72
|
+
reference: T::String[REF],
|
|
73
|
+
status: Status,
|
|
74
|
+
currency: Currency,
|
|
75
|
+
created_at: FormsDate,
|
|
76
|
+
updated_at: FormsDate,
|
|
77
|
+
shipped_at: (FormsDate | T::Nil),
|
|
78
|
+
subtotal: T::Lax::Decimal,
|
|
79
|
+
tax: T::Lax::Decimal,
|
|
80
|
+
total: T::Lax::Decimal,
|
|
81
|
+
customer: Customer,
|
|
82
|
+
items: T::Array[LineItem],
|
|
83
|
+
metadata: T::Hash[T::String, T::Lax::String],
|
|
84
|
+
notes: (T::String | T::Nil)
|
|
85
|
+
].freeze
|
|
86
|
+
|
|
87
|
+
def measure_generation(type, iters = ITERS)
|
|
88
|
+
type.to_json_schema(root: true) # warm up
|
|
89
|
+
GC.start
|
|
90
|
+
a0 = GC.stat(:total_allocated_objects)
|
|
91
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
92
|
+
iters.times { type.to_json_schema(root: true) }
|
|
93
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
94
|
+
a1 = GC.stat(:total_allocated_objects)
|
|
95
|
+
[(t1 - t0) * 1e9 / iters, (a1 - a0).to_f / iters]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
schema = ORDER.to_json_schema(root: true)
|
|
99
|
+
json_bytes = JSON.generate(schema).bytesize
|
|
100
|
+
us_per, allocs_per = measure_generation(ORDER)
|
|
101
|
+
|
|
102
|
+
puts "== plumb JSON Schema generation — Ruby #{RUBY_VERSION} =="
|
|
103
|
+
puts format('%-26s %s', 'payload', 'REST "Order" (nested: customer, 2 addresses, line items, enums, coercers)')
|
|
104
|
+
puts format('%-26s %.1f', 'us / generation', us_per / 1000.0)
|
|
105
|
+
puts format('%-26s %.0f', 'allocs / generation', allocs_per)
|
|
106
|
+
puts format('%-26s %d bytes', 'emitted schema size', json_bytes)
|
|
107
|
+
puts format('%-26s %d', 'top-level properties', (schema['properties'] || {}).size)
|
data/bench/plumb_hash.rb
CHANGED
|
@@ -12,16 +12,22 @@ module PlumbHash
|
|
|
12
12
|
BLANK_STRING = ''
|
|
13
13
|
MONEY_EXP = /(\W{1}|\w{3})?[\d+,.]/
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
# Blank string (or a valueless param) -> nil, otherwise an ISO date string -> Date.
|
|
16
|
+
# Mirrors DryTypesHash's `T::Params::Date.optional`, which is what this file is
|
|
17
|
+
# benchmarked against.
|
|
18
|
+
FormEncoders = Plumb::Codec::Forms
|
|
19
|
+
BlankStringOrDate = FormEncoders::NilEncoder | FormEncoders::DateEncoder
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
# STUB_MONEY swaps the Monetize-parsing constructor for an identity
|
|
22
|
+
# passthrough, so the benchmark can measure the schema WITHOUT the (shared)
|
|
23
|
+
# money-parsing cost that both Plumb and Dry pay equally.
|
|
24
|
+
Money = if ENV['STUB_MONEY']
|
|
25
|
+
Any
|
|
26
|
+
else
|
|
27
|
+
Any.build(::Money) do |v|
|
|
28
|
+
v.is_a?(::Money) ? v : Monetize.parse!(v.to_s.gsub(',', ''))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
25
31
|
|
|
26
32
|
Term = Hash[
|
|
27
33
|
name: String.default(BLANK_STRING),
|
|
@@ -48,7 +54,7 @@ module PlumbHash
|
|
|
48
54
|
name: String.present,
|
|
49
55
|
upfront_cost_description: String.default(BLANK_STRING),
|
|
50
56
|
tv_channels_count: Integer.default(0),
|
|
51
|
-
terms: Array[Term].
|
|
57
|
+
terms: Array[Term].where(size: 1..).default(BLANK_ARRAY),
|
|
52
58
|
tv_included: Boolean,
|
|
53
59
|
additional_info: String,
|
|
54
60
|
product_type: String.nullable,
|
|
@@ -58,7 +64,7 @@ module PlumbHash
|
|
|
58
64
|
name: String,
|
|
59
65
|
technology: String,
|
|
60
66
|
is_mobile: Boolean.default(false),
|
|
61
|
-
|
|
67
|
+
description: String,
|
|
62
68
|
technology_tags: Array[String].default(BLANK_ARRAY),
|
|
63
69
|
download_speed_measurement: String.default(BLANK_STRING),
|
|
64
70
|
download_speed: Numeric.default(0),
|