plumb 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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