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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/README.md +291 -19
- data/examples/command_objects.rb +207 -0
- data/examples/concurrent_downloads.rb +107 -0
- data/examples/csv_stream.rb +97 -0
- data/examples/programmers.csv +201 -0
- data/examples/weekdays.rb +66 -0
- data/lib/plumb/array_class.rb +25 -19
- data/lib/plumb/build.rb +3 -0
- data/lib/plumb/hash_class.rb +44 -13
- data/lib/plumb/hash_map.rb +34 -0
- data/lib/plumb/interface_class.rb +6 -4
- data/lib/plumb/json_schema_visitor.rb +117 -74
- data/lib/plumb/match_class.rb +8 -5
- data/lib/plumb/metadata.rb +3 -0
- data/lib/plumb/metadata_visitor.rb +45 -40
- data/lib/plumb/rules.rb +6 -7
- data/lib/plumb/schema.rb +37 -41
- data/lib/plumb/static_class.rb +4 -4
- data/lib/plumb/step.rb +6 -1
- data/lib/plumb/steppable.rb +36 -34
- data/lib/plumb/stream_class.rb +61 -0
- data/lib/plumb/tagged_hash.rb +12 -3
- data/lib/plumb/transform.rb +6 -1
- data/lib/plumb/tuple_class.rb +8 -5
- data/lib/plumb/types.rb +19 -60
- data/lib/plumb/value_class.rb +5 -2
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +13 -9
- data/lib/plumb.rb +1 -0
- metadata +8 -2
@@ -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] }
|
data/lib/plumb/array_class.rb
CHANGED
@@ -3,7 +3,7 @@
|
|
3
3
|
require 'concurrent'
|
4
4
|
require 'plumb/steppable'
|
5
5
|
require 'plumb/result'
|
6
|
-
require 'plumb/
|
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 =
|
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
|
-
|
39
|
-
|
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
|
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
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
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
|
data/lib/plumb/hash_class.rb
CHANGED
@@ -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
|
-
|
32
|
-
in [Steppable => key_type,
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
-
|
73
|
-
|
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
|
-
|
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
|
-
|
143
|
+
Steppable.wrap(hash)
|
113
144
|
end
|
114
145
|
end
|
115
146
|
|