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,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
- PARSE_MONEY = proc do |result|
16
- value = Monetize.parse!(result.value.to_s.gsub(',', ''))
17
- result.valid(value)
18
- end
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
- BlankStringOrDate = Forms::Nil | Forms::Date
21
-
22
- Money = Any[::Money] \
23
- | (String.present >> PARSE_MONEY) \
24
- | (Numeric >> PARSE_MONEY)
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].policy(size: 1..).default(BLANK_ARRAY),
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
- desciption: String,
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),