plumb 0.0.1 → 0.0.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.
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+ Bundler.setup(:examples)
5
+ require 'plumb'
6
+ require 'open-uri'
7
+ require 'fileutils'
8
+ require 'digest/md5'
9
+
10
+ # Mixin built-in Plumb types, and provide a namespace for core types and
11
+ # pipelines in this example.
12
+ module Types
13
+ include Plumb::Types
14
+
15
+ # Turn a string into an URI
16
+ URL = String[/^https?:/].build(::URI, :parse)
17
+
18
+ # a Struct to holw image data
19
+ Image = Data.define(:url, :io)
20
+
21
+ # A (naive) step to download files from the internet
22
+ # and return an Image struct.
23
+ # It implements the #call(Result) => Result interface.
24
+ # required by all Plumb steps.
25
+ # URI => Image
26
+ Download = Plumb::Step.new do |result|
27
+ io = URI.open(result.value)
28
+ result.valid(Image.new(result.value.to_s, io))
29
+ end
30
+
31
+ # A configurable file-system cache to read and write files from.
32
+ class Cache
33
+ def initialize(dir = '.')
34
+ @dir = dir
35
+ FileUtils.mkdir_p(dir)
36
+ end
37
+
38
+ # Wrap the #reader and #wruter methods into Plumb steps
39
+ # A step only needs #call(Result) => Result to work in a pipeline,
40
+ # but wrapping it in Plumb::Step provides the #>> and #| methods for composability,
41
+ # as well as all the other helper methods provided by the Steppable module.
42
+ def read = Plumb::Step.new(method(:reader))
43
+ def write = Plumb::Step.new(method(:writer))
44
+
45
+ private
46
+
47
+ # URL => Image
48
+ def reader(result)
49
+ path = path_for(result.value)
50
+ return result.invalid(errors: "file #{path} does not exist") unless File.exist?(path)
51
+
52
+ result.valid Types::Image.new(url: path, io: File.new(path))
53
+ end
54
+
55
+ # Image => Image
56
+ def writer(result)
57
+ image = result.value
58
+ path = path_for(image.url)
59
+ File.open(path, 'wb') { |f| f.write(image.io.read) }
60
+ result.valid image.with(url: path, io: File.new(path))
61
+ end
62
+
63
+ def path_for(url)
64
+ url = url.to_s
65
+ ext = File.extname(url)
66
+ name = [Digest::MD5.hexdigest(url), ext].compact.join
67
+ File.join(@dir, name)
68
+ end
69
+ end
70
+ end
71
+
72
+ ###################################
73
+ # Program 1: idempoent download of images from the internet
74
+ # If not present in the cache, images are downloaded and written to the cache.
75
+ # Otherwise images are listed directly from the cache (files on disk).
76
+ ###################################
77
+
78
+ cache = Types::Cache.new('./examples/data/downloads')
79
+
80
+ # A pipeline representing a single image download.
81
+ # 1). Take a valid URL string.
82
+ # 2). Attempt reading the file from the cache. Return that if it exists.
83
+ # 3). Otherwise, download the file from the internet and write it to the cache.
84
+ IdempotentDownload = Types::URL >> (cache.read | (Types::Download >> cache.write))
85
+
86
+ # An array of downloadable images,
87
+ # marked as concurrent so that all IO operations are run in threads.
88
+ Images = Types::Array[IdempotentDownload].concurrent
89
+
90
+ urls = [
91
+ 'https://as1.ftcdn.net/v2/jpg/07/67/24/52/1000_F_767245234_NdiDr9LOkypOEKtXiDDoM1m42zBQ0hZe.jpg',
92
+ 'https://as1.ftcdn.net/v2/jpg/07/83/02/00/1000_F_783020069_HaP9UCZs2UXUnKxpGHDoddt0vuX4vU9U.jpg',
93
+ 'https://as2.ftcdn.net/v2/jpg/07/32/27/53/1000_F_732275398_r2t1cnxSXGUkZSgxtqhg40UupKiqcywJ.jpg',
94
+ 'https://as1.ftcdn.net/v2/jpg/07/46/41/18/1000_F_746411866_WwQBojO7xMeVFTua2BuEZdKGDI2vsgAH.jpg',
95
+ 'https://as2.ftcdn.net/v2/jpg/07/43/50/53/1000_F_743505311_MJ3zo09rH7rUvHrCKlBotojm6GLw3SCT.jpg',
96
+ 'https://images.pexels.com/photos/346529/pexels-photo-346529.jpeg'
97
+ ]
98
+
99
+ # raise CachedDownload.parse(url).inspect
100
+ # raise CachedDownload.parse(urls.first).inspect
101
+ # Run the program. The images are downloaded and written to the ./downloads directory.
102
+ # Running this multiple times will only download the images once, and list them from the cache.
103
+ # You can try deleting all or some of the files and re-running.
104
+ Images.resolve(urls).tap do |result|
105
+ puts result.valid? ? 'valid!' : result.errors
106
+ result.value.each { |img| p img }
107
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'plumb'
5
+ require 'csv'
6
+
7
+ # Defines types and pipelines for opening and working with CSV streams.
8
+ # Run with `bundle exec ruby examples/csv_stream.rb`
9
+ module Types
10
+ include Plumb::Types
11
+
12
+ # Open a File
13
+ # ex. file = FileStep.parse('./files/data.csv') # => File
14
+ OpenFile = String
15
+ .check('no file for that path') { |s| ::File.exist?(s) }
16
+ .build(::File)
17
+
18
+ # Turn a File into a CSV stream
19
+ # ex. csv_enum = FileToCSV.parse(file) #=> Enumerator
20
+ FileToCSV = Types::Any[::File]
21
+ .build(CSV)
22
+ .transform(::Enumerator, &:each)
23
+
24
+ # Turn a string file path into a CSV stream
25
+ # ex. csv_enum = StrinToCSV.parse('./files/data.csv') #=> Enumerator
26
+ StringToCSV = OpenFile >> FileToCSV
27
+ end
28
+
29
+ #################################################
30
+ # Program 1: stream a CSV list of programmers and filter them by age.
31
+ #################################################
32
+
33
+ # This is a CSV row for a programmer over the age of 18.
34
+ AdultProgrammer = Types::Tuple[
35
+ # Name
36
+ String,
37
+ # Age. Coerce to Integer and constrain to 18 or older.
38
+ Types::Lax::Integer[18..],
39
+ # Programming language
40
+ String
41
+ ]
42
+
43
+ # An Array of AdultProgrammer.
44
+ AdultProgrammerArray = Types::Array[AdultProgrammer]
45
+
46
+ # A pipeline to open a file, parse CSV and stream rows of AdultProgrammer.
47
+ AdultProgrammerStream = Types::StringToCSV >> AdultProgrammerArray.stream
48
+
49
+ # List adult programmers from file.
50
+ puts 'Adult programmers:'
51
+ AdultProgrammerStream.parse('./examples/programmers.csv').each do |row|
52
+ puts row.value.inspect if row.valid?
53
+ end
54
+
55
+ # The filtering can also be achieved with Stream#filter
56
+ #  AdultProgrammerStream = Types::StringToCSV >> AdultProgrammerArray.stream.filtered
57
+
58
+ #################################################
59
+ # Program 2: list Ruby programmers from a CSV file.
60
+ #################################################
61
+
62
+ RubyProgrammer = Types::Tuple[
63
+ String, # Name
64
+ Types::Lax::Integer, # Age
65
+ Types::String[/^ruby$/i] # Programming language
66
+ ]
67
+
68
+ # A pipeline to open a file, parse CSV and stream rows of AdultProgrammer.
69
+ # This time we use Types::Stream directly.
70
+ RubyProgrammerStream = Types::StringToCSV >> Types::Stream[RubyProgrammer].filtered
71
+
72
+ # List Ruby programmers from file.
73
+ puts
74
+ puts '----------------------------------------'
75
+ puts 'Ruby programmers:'
76
+ RubyProgrammerStream.parse('./examples/programmers.csv').each do |person|
77
+ puts person.inspect
78
+ end
79
+
80
+ # We can filter Ruby OR Elixir programmers with a union type.
81
+ # Lang = Types::String['ruby'] | Types::String['elixir']
82
+ # Or with allowe values:
83
+ # Lang = Types::String.options(%w[ruby elixir])
84
+
85
+ #################################################
86
+ # Program 3: negate the stream above to list non-Ruby programmers.
87
+ #################################################
88
+
89
+ # See the `.not` which negates the type.
90
+ NonRubyProgrammerStream = Types::StringToCSV >> Types::Stream[RubyProgrammer.not].filtered
91
+
92
+ puts
93
+ puts '----------------------------------------'
94
+ puts 'NON Ruby programmers:'
95
+ NonRubyProgrammerStream.parse('./examples/programmers.csv').each do |person|
96
+ puts person.inspect
97
+ end
@@ -0,0 +1,201 @@
1
+ name,age,language
2
+ Krysten Tromp,57,C++
3
+ Irena Mayert III,21,ruby
4
+ Lucile Jacobi I,28,lisp
5
+ Warner Ullrich DC,64,rust
6
+ Refugio Cronin,13,F#
7
+ Emery Kris,84,ocaml
8
+ Sherise Wisoky,73,elm
9
+ Bridget DuBuque,75,elixir
10
+ Maynard Lubowitz V,58,C++
11
+ Rosamaria Heidenreich,61,fortran
12
+ Marla Fritsch,70,golang
13
+ Williams Little MD,48,golang
14
+ Bernard Wilderman,25,ocaml
15
+ Sen. Hiram Bayer,37,gleam
16
+ Ginger Mueller,60,elixir
17
+ Karyl Hegmann,63,ruby
18
+ Terrilyn Fadel,40,ocaml
19
+ Edward Abshire,14,gleam
20
+ Clyde Lesch,67,elixir
21
+ Ms. Loretta Botsford,40,ruby
22
+ Sen. Susann Gleichner,19,zig
23
+ Von Thompson,80,javascript
24
+ Carrol Kirlin II,43,ocaml
25
+ Samual Welch,17,elm
26
+ Samara Hegmann II,50,fortran
27
+ Tyron Bogan,65,rust
28
+ Juan Quitzon,57,zig
29
+ Bev Feest CPA,15,javascript
30
+ Walter Parisian,17,golang
31
+ The Hon. Zachary Funk,55,ruby
32
+ Jaimie Stoltenberg,77,rust
33
+ Jarrod Turcotte DC,68,elixir
34
+ Dr. Jason Jacobs,40,zig
35
+ Miss Neal Leffler,17,rust
36
+ Wilfredo Kemmer,30,C++
37
+ Fr. Leon Jerde,35,ruby
38
+ Mr. Ray Anderson,31,C
39
+ Jed Skiles,63,lua
40
+ Delena Raynor LLD,40,ruby
41
+ Pres. Dirk Kunde,74,rust
42
+ Franklyn Kuhic,55,ruby
43
+ Jon Deckow,68,golang
44
+ Anglea Beier LLD,17,C++
45
+ Jene Labadie,60,lisp
46
+ Ezequiel Wyman,50,F#
47
+ Jessie Medhurst,33,F#
48
+ Luke Bergnaum,57,ruby
49
+ Douglass Goodwin,83,zig
50
+ Garry Farrell,67,ocaml
51
+ Warren Harber III,23,rust
52
+ Britni Mohr,60,elixir
53
+ Terra Pouros DDS,60,F#
54
+ Pia Bernhard,37,fortran
55
+ Akiko Hoppe,14,ocaml
56
+ Samuel Muller,56,rust
57
+ Esperanza Kutch,67,C++
58
+ Hal Gislason,75,ruby
59
+ Prof. Jonell Turcotte,61,ruby
60
+ Lindsey Hermiston,29,ruby
61
+ Deadra Muller,51,fortran
62
+ Amb. Fern Crist,58,elm
63
+ Enola Grant,39,zig
64
+ Mickey Lueilwitz DVM,80,lua
65
+ Raymon Jacobi,34,golang
66
+ Jody Rowe V,27,golang
67
+ Lilliam Hyatt,36,zig
68
+ Fr. Larraine Klein,22,javascript
69
+ Derick Rutherford DC,41,C
70
+ Eugene Kiehn DVM,20,C
71
+ Dianna Kunze DDS,22,fortran
72
+ Denis Reilly,67,zig
73
+ Alejandrina Waelchi,70,rust
74
+ Odell Cassin,20,ocaml
75
+ Travis Daugherty,77,F#
76
+ Guillermo McCullough,15,ruby
77
+ Malena Nitzsche,12,lisp
78
+ Jess Gleason,61,ruby
79
+ Mr. Kenton Quitzon,77,C++
80
+ Sixta Torp,36,ruby
81
+ Teddy McClure,15,ruby
82
+ Alexandra Towne CPA,73,elixir
83
+ Harrison Hartmann CPA,66,lua
84
+ Gretta Kirlin Sr.,17,rust
85
+ Rev. Jordan Yost,36,ruby
86
+ Geraldo Yundt LLD,17,elixir
87
+ Emelia Pouros,31,fortran
88
+ Msgr. Coletta Crist,43,zig
89
+ Vernice Pfannerstill,31,zig
90
+ Rev. Marvel Mayer,55,ruby
91
+ Amb. Chanelle Ruecker,14,elixir
92
+ Sallie Roob PhD,75,gleam
93
+ Enoch Funk,33,golang
94
+ Alex Reichel,85,F#
95
+ Laure Boehm,24,julia
96
+ Valeri Dietrich,72,ruby
97
+ Tabatha Kerluke,58,julia
98
+ Bert Sauer,73,gleam
99
+ Morgan Heaney,39,julia
100
+ Lora Hackett,74,javascript
101
+ Rona Leuschke,72,rust
102
+ Mora Bode,19,elm
103
+ Daniell Hegmann,37,golang
104
+ Richie Waters CPA,23,javascript
105
+ Katharine Huel,36,C
106
+ Lauren Abbott,37,C
107
+ Daisey Cremin,62,ruby
108
+ Evelyn Koch,49,F#
109
+ Pres. Cassey Hammes,82,gleam
110
+ Msgr. Wilhelmina Bauch,18,C
111
+ Hilaria Emmerich,35,rust
112
+ Rev. Julieann Hickle,18,C
113
+ Pres. Corey Shields,14,ruby
114
+ Donetta Lehner PhD,34,ruby
115
+ Cyndi Schumm,83,ruby
116
+ Newton West,52,elm
117
+ Carleen Wisozk,42,F#
118
+ Alysha Marvin Ret.,35,lisp
119
+ Alex Balistreri,83,javascript
120
+ Zulma Parker PhD,45,ocaml
121
+ Reyes Bogisich Sr.,70,ocaml
122
+ Dario Batz,38,elm
123
+ Jacquelyn MacGyver,59,gleam
124
+ Gov. Zachary Schuster,64,ruby
125
+ Leonard Braun,65,ruby
126
+ Ivette Schuppe,30,ruby
127
+ Adrian Parisian,45,elm
128
+ Dr. Young Rohan,20,fortran
129
+ Dong Hansen,77,F#
130
+ Johnny Hammes,44,F#
131
+ Amb. Terence Cummerata,49,ruby
132
+ Taylor Nitzsche,40,F#
133
+ Mr. Bennett Price,46,C
134
+ Genaro Blick VM,23,fortran
135
+ Ashley Osinski,71,ruby
136
+ Jae D'Amore,50,elm
137
+ Katherine Ward,63,lisp
138
+ Anderson O'Conner,70,ocaml
139
+ Amb. Chrystal Schmitt,70,rust
140
+ Vaughn Larkin,52,F#
141
+ Jada Brown Jr.,53,golang
142
+ Pauletta Krajcik,72,gleam
143
+ Cinthia Morar,26,ruby
144
+ Loyce Windler,51,julia
145
+ Ms. Sharee Walker,84,ruby
146
+ Raymundo Kohler,14,fortran
147
+ Natalya Towne Sr.,72,C++
148
+ Rayford Kemmer,12,golang
149
+ Rev. Tisa Harber,44,elm
150
+ Jorge Schaden,72,lua
151
+ Pam Marquardt,12,lua
152
+ Xenia Wisoky,13,javascript
153
+ Audrea Purdy,75,lua
154
+ Joesph Russel DC,18,javascript
155
+ Marty Ratke III,38,rust
156
+ Jordon Bradtke,25,C
157
+ Titus Cronin Esq.,36,ruby
158
+ Ms. Lucio Vandervort,64,golang
159
+ Theo Nicolas,50,julia
160
+ Hazel Pouros,29,ruby
161
+ Rey Lubowitz,59,elixir
162
+ Ike Hessel,66,C
163
+ Alec Wilderman,42,ruby
164
+ Leesa Ledner,71,lisp
165
+ Jeneva Gutkowski,33,lua
166
+ Isaias MacGyver,32,F#
167
+ Daryl Senger I,36,ruby
168
+ Mr. Gonzalo Breitenberg,50,julia
169
+ Aretha Halvorson,17,ocaml
170
+ Ralph Wisoky,15,F#
171
+ Demarcus Stehr,80,javascript
172
+ Sheldon Lemke,47,golang
173
+ Kimiko Hermiston,20,golang
174
+ Manda Pfannerstill,20,F#
175
+ Haywood Rowe,85,F#
176
+ Roxanne Gerhold,29,ruby
177
+ Chanelle Beatty,70,ruby
178
+ Shirlee Hoeger,18,ruby
179
+ Beula Denesik,68,C++
180
+ Roberto Lang,59,gleam
181
+ Marcy Rau,77,javascript
182
+ Samira Hilll,51,F#
183
+ Clarissa Simonis,82,javascript
184
+ Elijah Cole,57,ocaml
185
+ Freida Wilkinson,34,lua
186
+ Polly Wehner,82,C++
187
+ Lindsey Ullrich,80,elixir
188
+ Freddie Auer,42,ruby
189
+ Luz Zemlak,37,lisp
190
+ Colin Emard,74,ruby
191
+ Dion Hudson,56,gleam
192
+ Daisy Crist,12,lua
193
+ Keturah Ortiz,64,ruby
194
+ Everette Shanahan Sr.,28,zig
195
+ Prof. Beckie Wiza,80,ruby
196
+ Msgr. Annabelle Howe,35,ocaml
197
+ Jeremy Effertz,22,julia
198
+ Dinorah Graham,38,elixir
199
+ Kate Bernier,35,ruby
200
+ Jerrold Ortiz,22,ruby
201
+ Bart Moen,74,ruby
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+ Bundler.setup(:examples)
5
+ require 'plumb'
6
+
7
+ # bundle exec examples/weekdays.rb
8
+ #
9
+ # Data types to represent and parse an array of days of the week.
10
+ # Input data can be an array of day names or numbers, ex.
11
+ # ['monday', 'tuesday', 'wednesday']
12
+ # [1, 2, 3]
13
+ #
14
+ # Or mixed:
15
+ # [1, 'Tuesday', 3]
16
+ #
17
+ # Validate that there aren't repeated days, ex. [1, 2, 4, 2]
18
+ # The output is an array of day numbers, ex. [1, 2, 3]
19
+ module Types
20
+ include Plumb::Types
21
+
22
+ DAYS = {
23
+ 'monday' => 1,
24
+ 'tuesday' => 2,
25
+ 'wednesday' => 3,
26
+ 'thursday' => 4,
27
+ 'friday' => 5,
28
+ 'saturday' => 6,
29
+ 'sunday' => 7
30
+ }.freeze
31
+
32
+ # Validate that a string is a valid day name, down-case it.
33
+ DayName = String
34
+ .transform(::String, &:downcase)
35
+ .options(DAYS.keys)
36
+
37
+ # Turn a day name into its number, or validate that a number is a valid day number.
38
+ DayNameOrNumber = DayName.transform(::Integer) { |v| DAYS[v] } | Integer.options(DAYS.values)
39
+
40
+ # An Array for days of the week, with no repeated days.
41
+ # Ex. [1, 2, 3, 4, 5, 6, 7], [1, 2, 4], ['monday', 'tuesday', 'wednesday', 7]
42
+ # Turn day names into numbers, and sort the array.
43
+ Week = Array[DayNameOrNumber]
44
+ .rule(size: 1..7)
45
+ .check('repeated days') { |days| days.uniq.size == days.size }
46
+ .transform(::Array, &:sort)
47
+ end
48
+
49
+ p Types::DayNameOrNumber.parse('monday') # => 1
50
+ p Types::DayNameOrNumber.parse(3) # => 3
51
+ p Types::DayName.parse('TueSday') # => "tuesday
52
+ p Types::Week.parse([3, 2, 1, 4, 5, 6, 7]) # => [1, 2, 3, 4, 5, 6, 7]
53
+ p Types::Week.parse([1, 'Tuesday', 3, 4, 5, 'saturday', 7]) # => [1, 2, 3, 4, 5, 6, 7]
54
+
55
+ # p Types::Week[[1, 1, 3, 4, 5, 6, 7]] # raises Plumb::TypeError: repeated days
56
+ #
57
+ # Or use these types as part of other composite types, ex.
58
+ #
59
+ # PartTimeJob = Types::Hash[
60
+ # role: Types::String.present,
61
+ # days_of_the_week: Types::Week
62
+ # ]
63
+ #
64
+ # result = PartTimeJob.resolve(role: 'Ruby dev', days_of_the_week: %w[Tuesday Wednesday])
65
+ # result.valid? # true
66
+ # result.value # { role: 'Ruby dev', days_of_the_week: [2, 3] }
@@ -3,7 +3,7 @@
3
3
  require 'concurrent'
4
4
  require 'plumb/steppable'
5
5
  require 'plumb/result'
6
- require 'plumb/hash_class'
6
+ require 'plumb/stream_class'
7
7
 
8
8
  module Plumb
9
9
  class ArrayClass
@@ -12,15 +12,7 @@ module Plumb
12
12
  attr_reader :element_type
13
13
 
14
14
  def initialize(element_type: Types::Any)
15
- @element_type = case element_type
16
- when Steppable
17
- element_type
18
- when ::Hash
19
- HashClass.new(element_type)
20
- else
21
- raise ArgumentError,
22
- "element_type #{element_type.inspect} must be a Steppable"
23
- end
15
+ @element_type = Steppable.wrap(element_type)
24
16
 
25
17
  freeze
26
18
  end
@@ -35,12 +27,22 @@ module Plumb
35
27
  ConcurrentArrayClass.new(element_type:)
36
28
  end
37
29
 
38
- private def _inspect
39
- %(#{name}[#{element_type}])
30
+ def stream
31
+ StreamClass.new(element_type:)
32
+ end
33
+
34
+ def filtered
35
+ MatchClass.new(::Array) >> Step.new(nil, "Array[#{element_type}].filtered") do |result|
36
+ arr = result.value.each.with_object([]) do |e, memo|
37
+ r = element_type.resolve(e)
38
+ memo << r.value if r.valid?
39
+ end
40
+ result.valid(arr)
41
+ end
40
42
  end
41
43
 
42
44
  def call(result)
43
- return result.invalid(errors: 'is not an Array') unless result.value.is_a?(::Enumerable)
45
+ return result.invalid(errors: 'is not an Array') unless ::Array === result.value
44
46
 
45
47
  values, errors = map_array_elements(result.value)
46
48
  return result.valid(values) unless errors.any?
@@ -50,6 +52,10 @@ module Plumb
50
52
 
51
53
  private
52
54
 
55
+ def _inspect
56
+ %(Array[#{element_type}])
57
+ end
58
+
53
59
  def map_array_elements(list)
54
60
  # Reuse the same result object for each element
55
61
  # to decrease object allocation.
@@ -73,12 +79,12 @@ module Plumb
73
79
  errors = {}
74
80
 
75
81
  values = list
76
- .map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
77
- .map.with_index do |f, idx|
78
- re = f.value
79
- errors[idx] = f.reason if f.rejected?
80
- re.value
81
- end
82
+ .map { |e| Concurrent::Future.execute { element_type.resolve(e) } }
83
+ .map.with_index do |f, idx|
84
+ re = f.value
85
+ errors[idx] = f.reason if f.rejected?
86
+ re.value
87
+ end
82
88
 
83
89
  [values, errors]
84
90
  end
data/lib/plumb/build.rb CHANGED
@@ -11,8 +11,11 @@ module Plumb
11
11
  def initialize(type, factory_method: :new, &block)
12
12
  @type = type
13
13
  @block = block || ->(value) { type.send(factory_method, value) }
14
+ freeze
14
15
  end
15
16
 
16
17
  def call(result) = result.valid(@block.call(result.value))
18
+
19
+ private def _inspect = "Build[#{@type.inspect}]"
17
20
  end
18
21
  end
@@ -12,8 +12,9 @@ module Plumb
12
12
 
13
13
  attr_reader :_schema
14
14
 
15
- def initialize(schema = {})
15
+ def initialize(schema: BLANK_HASH, inclusive: false)
16
16
  @_schema = wrap_keys_and_values(schema)
17
+ @inclusive = inclusive
17
18
  freeze
18
19
  end
19
20
 
@@ -28,12 +29,14 @@ module Plumb
28
29
  def schema(*args)
29
30
  case args
30
31
  in [::Hash => hash]
31
- self.class.new(_schema.merge(wrap_keys_and_values(hash)))
32
- in [Steppable => key_type, Steppable => value_type]
33
- HashMap.new(key_type, value_type)
34
- else
35
- raise ::ArgumentError, "unexpected value to Types::Hash#schema #{args.inspect}"
36
- end
32
+ self.class.new(schema: _schema.merge(wrap_keys_and_values(hash)), inclusive: @inclusive)
33
+ in [Steppable => key_type, value_type]
34
+ HashMap.new(key_type, Steppable.wrap(value_type))
35
+ in [Class => key_type, value_type]
36
+ HashMap.new(Steppable.wrap(key_type), Steppable.wrap(value_type))
37
+ else
38
+ raise ::ArgumentError, "unexpected value to Types::Hash#schema #{args.inspect}"
39
+ end
37
40
  end
38
41
 
39
42
  alias [] schema
@@ -45,7 +48,7 @@ module Plumb
45
48
  def +(other)
46
49
  raise ArgumentError, "expected a HashClass, got #{other.class}" unless other.is_a?(HashClass)
47
50
 
48
- self.class.new(merge_rightmost_keys(_schema, other._schema))
51
+ self.class.new(schema: merge_rightmost_keys(_schema, other._schema), inclusive: @inclusive)
49
52
  end
50
53
 
51
54
  def &(other)
@@ -56,21 +59,43 @@ module Plumb
56
59
  memo[k] = other.at_key(k)
57
60
  end
58
61
 
59
- self.class.new(intersected)
62
+ self.class.new(schema: intersected, inclusive: @inclusive)
60
63
  end
61
64
 
62
65
  def tagged_by(key, *types)
63
66
  TaggedHash.new(self, key, types)
64
67
  end
65
68
 
69
+ def inclusive
70
+ self.class.new(schema: _schema, inclusive: true)
71
+ end
72
+
66
73
  def at_key(a_key)
67
74
  _schema[Key.wrap(a_key)]
68
75
  end
69
76
 
70
77
  def to_h = _schema
71
78
 
72
- private def _inspect
73
- %(#{name}[#{_schema.map { |(k, v)| [k.inspect, v.inspect].join(':') }.join(' ')}])
79
+ def filtered
80
+ op = lambda do |result|
81
+ return result.invalid(errors: 'must be a Hash') unless result.value.is_a?(::Hash)
82
+ return result unless _schema.any?
83
+
84
+ input = result.value
85
+ field_result = BLANK_RESULT.dup
86
+ output = _schema.each.with_object({}) do |(key, field), ret|
87
+ key_s = key.to_sym
88
+ if input.key?(key_s)
89
+ r = field.call(field_result.reset(input[key_s]))
90
+ ret[key_s] = r.value if r.valid?
91
+ elsif !key.optional?
92
+ r = field.call(BLANK_RESULT)
93
+ ret[key_s] = r.value if r.valid?
94
+ end
95
+ end
96
+ result.valid(output)
97
+ end
98
+ Step.new(op, [_inspect, 'filtered'].join('.'))
74
99
  end
75
100
 
76
101
  def call(result)
@@ -80,7 +105,9 @@ module Plumb
80
105
  input = result.value
81
106
  errors = {}
82
107
  field_result = BLANK_RESULT.dup
83
- output = _schema.each.with_object({}) do |(key, field), ret|
108
+ initial = {}
109
+ initial = initial.merge(input) if @inclusive
110
+ output = _schema.each.with_object(initial) do |(key, field), ret|
84
111
  key_s = key.to_sym
85
112
  if input.key?(key_s)
86
113
  r = field.call(field_result.reset(input[key_s]))
@@ -98,6 +125,10 @@ module Plumb
98
125
 
99
126
  private
100
127
 
128
+ def _inspect
129
+ %(Hash[#{_schema.map { |(k, v)| [k.inspect, v.inspect].join(': ') }.join(', ')}])
130
+ end
131
+
101
132
  def wrap_keys_and_values(hash)
102
133
  case hash
103
134
  when ::Array
@@ -109,7 +140,7 @@ module Plumb
109
140
  when Callable
110
141
  hash
111
142
  else #  leaf values
112
- StaticClass.new(hash)
143
+ Steppable.wrap(hash)
113
144
  end
114
145
  end
115
146