plumb 0.0.14 → 0.0.16

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bfaf49dcfcb3b25cc3dbca406efe1c666284df83466ee87c5b7faf63d613ab04
4
- data.tar.gz: 7a95598acd4e07180d68b17a6b72d3fddf00aa293503bdc353a37c585dcc95c0
3
+ metadata.gz: 13814b82e58449726341c0a26a398fee38818f852fe09f19ba7754a99b7c54ce
4
+ data.tar.gz: 2205b2bc5f48b19b417afe8403cdbd7afbcac3689307b6ba69237ad094718815
5
5
  SHA512:
6
- metadata.gz: 44fb44f7f2c6650ad1dc9a68b7945200bd87df874246ee0785d11e4fc936db89a552dca473753303e34cbecb8764fc2272d9c6829427664175bfaba1fe876623
7
- data.tar.gz: 6b27b375b6b2df1188ec821a918325a9ff97bd0b35c65ee16d4826ef8b018ee497f18e461a2e472281f50a3754c835347a49943dd3fdf0dac1aa949aea3b3145
6
+ metadata.gz: 604b2d74cf5a78578471d5e6cf523f956025ea95c2e9b17dec7337cca41de10dc2254bc66b69ea2255ee18059b885ef6bb5bd64ea32a34a763cf095168c80022
7
+ data.tar.gz: 50e065d933d9c3a00ff04a0e3e2375997d43039fc8d5c3ba6c7c1a6dc9455bda9d056bfefb11e81b4c75f0a26c01919d7f18c5b0da6aefa33c1f668ddabbaa29
data/.rubocop.yml CHANGED
@@ -1,5 +1,11 @@
1
1
  AllCops:
2
- TargetRubyVersion: 3.2.2
2
+ TargetRubyVersion: 3.4
3
+ inherit_from:
4
+ - ~/.rubocop.yml
5
+ Style/StringLiterals:
6
+ Enabled: true
7
+ Style/Documentation:
8
+ Enabled: false
3
9
  Style/CaseEquality:
4
10
  Enabled: false
5
11
  Style/LambdaCall:
data/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  **This library is work in progress!**
4
4
 
5
- Composable data validation, coercion and processing in Ruby. Takes over from https://github.com/ismasan/parametric
5
+ Composable data validation, coercion and processing in Ruby. Takes over from [https://github.com/ismasan/parametric](https://github.com/ismasan/parametric)
6
6
 
7
- This library takes ideas from the excellent https://dry-rb.org ecosystem, with some of the features offered by Dry-Types, Dry-Schema, Dry-Struct. However, I'm aiming at a subset of the functionality with a (hopefully) smaller API surface and fewer concepts, focusing on lessons learned after using Parametric in production for many years.
7
+ This library takes ideas from the excellent [https://dry-rb.org](https://dry-rb.org) ecosystem, with some of the features offered by Dry-Types, Dry-Schema, Dry-Struct. However, I'm aiming at a subset of the functionality with a (hopefully) smaller API surface and fewer concepts, focusing on lessons learned after using Parametric in production for many years.
8
8
 
9
9
  If you're after raw performance and versatility I strongly recommend you use the Dry gems.
10
10
 
@@ -135,7 +135,7 @@ joe = User.parse({ name: 'Joe', email: 'joe@email.com', age: 20}) # returns vali
135
135
  Users.parse([joe]) # returns valid array of user hashes
136
136
  ```
137
137
 
138
- More about [Types::Hash](#typeshash) and [Types::Array](#typesarray). There's also [tuples](#typestuple), [hash maps](#hash-maps), [data structs](#typesdata) and [streams](#typesstream), and it's possible to create your own composite types.
138
+ More about [Types::Hash](#typeshash) and [Types::Array](#typesarray). There's also [tuples](#typestuple), [hash maps](#maps), [data structs](#typesdata) and [streams](#typesstream), and it's possible to create your own composite types.
139
139
 
140
140
  ### Type composition
141
141
 
@@ -232,6 +232,7 @@ You can see more use cases in [the examples directory](https://github.com/ismasa
232
232
  * `Types::Numeric`
233
233
  * `Types::String`
234
234
  * `Types::Hash`
235
+ * `Types::SymbolizedHash`
235
236
  * `Types::UUID::V4`
236
237
  * `Types::Email`
237
238
  * `Types::Date`
@@ -328,26 +329,26 @@ type.resolve(['a', 'a', 'b']) # Valid
328
329
  type.resolve(['a', 'x', 'b']) # Failure
329
330
  ```
330
331
 
331
- ### `#with`
332
+ ### `#where`
332
333
 
333
- The `#with` helper matches attributes of the object with values, using `#===`.
334
+ The `#where` helper matches attributes of the object with values, using `#===`.
334
335
 
335
336
  ```ruby
336
- LimitedArray = Types::Array[String].with(size: 10)
337
- LimitedString = Types::String.with(size: 10)
338
- LimitedSet = Types::Any[Set].with(size: 10)
337
+ LimitedArray = Types::Array[String].where(size: 10)
338
+ LimitedString = Types::String.where(size: 10)
339
+ LimitedSet = Types::Any[Set].where(size: 10)
339
340
  ```
340
341
 
341
342
  The size is matched via `#===`, so ranges also work.
342
343
 
343
344
  ```ruby
344
- Password = Types::String.with(bytesize: 10..20)
345
+ Password = Types::String.where(bytesize: 10..20)
345
346
  ```
346
347
 
347
348
  The helper accepts multiple attribute/value pairs
348
349
 
349
350
  ```ruby
350
- JoeBloggs = Types::Any[User].with(first_name: 'Joe', last_name: 'Bloggs')
351
+ JoeBloggs = Types::Any[User].where(first_name: 'Joe', last_name: 'Bloggs')
351
352
  ```
352
353
 
353
354
  #### `#transform`
@@ -838,7 +839,19 @@ User.parse(name: 'Joe', age: 40) # => { name: 'Joe', age: 40 }
838
839
  User.parse(name: 'Joe', age: 'nope') # => { name: 'Joe' }
839
840
  ```
840
841
 
841
- ### Hash maps
842
+ ### `Types::SymbolizedHash`
843
+
844
+ This type turns a hash's keys into symbols by calling `#to_sym` on them, and returning a new Hash.
845
+
846
+ ```ruby
847
+ # Make sure to symbolize keys first
848
+ type = Types::SymbolizedHash > Types::Hash[name: String, age: Integer]
849
+ type.parse('name' => 'Joe', 'age' => 20) # {name: 'Joe', age: 20}
850
+ ```
851
+
852
+
853
+
854
+ ### maps
842
855
 
843
856
  You can also use Hash syntax to define a hash map with specific types for all keys and values:
844
857
 
@@ -1026,7 +1039,7 @@ end
1026
1039
 
1027
1040
  ### Types::Data
1028
1041
 
1029
- `Types::Data` provides a superclass to define **inmutable** structs or value objects with typed / coercible attributes.
1042
+ `Types::Data` provides a superclass to define **immutable** structs or value objects with typed / coercible attributes.
1030
1043
 
1031
1044
  #### `[]` Syntax
1032
1045
 
@@ -1177,7 +1190,51 @@ Using `attribute?` allows for optional attributes. If the attribute is not prese
1177
1190
  attribute? :company, Company
1178
1191
  ```
1179
1192
 
1193
+ #### Before steps, symbolizing keys
1194
+
1195
+ The optional `.step` helper adds arbitrary Plumb steps to a Data constructor's internal pipeline.
1196
+
1197
+ This pipeline processes input data when initialising a Data instance.
1198
+
1199
+ This example adds the built-in `Types::SymbolizedHash` type to make sure struct inputs are symbolised before processing.
1200
+
1201
+ ```ruby
1202
+ class Person < Types::Data
1203
+ step Types::SymbolizedHash
1204
+
1205
+ attribute :name, String
1206
+ attribute :age, Integer
1207
+ end
1208
+
1209
+ # String keys will be symbolised now
1210
+ person = Person.new('name' => 'Joe', 'age' => 40)
1211
+ person.name # 'Joe'
1212
+ person.to_h # => { name: 'Joe', age: 40 }
1213
+ ```
1214
+
1215
+ Inline blocks can be registered as steps
1216
+
1217
+ ```ruby
1218
+ class Person < Types::Data
1219
+ # upcase all values
1220
+ step do |r|
1221
+ upcased = r.value.transform_values(&:upcase)
1222
+ r.valid upcased
1223
+ end
1224
+
1225
+ attribute :name, String
1226
+ attribute :last_name, String
1227
+ end
1228
+
1229
+ person = Person.new(name: 'joe', last_name: 'bloggs')
1230
+ person.name # => 'JOE'
1231
+ person.last_name # => 'BLOGGS'
1232
+ ```
1233
+
1234
+ A Data class steps are inherited to its child classes.
1235
+
1180
1236
  #### Inheritance
1237
+
1181
1238
  Data structs can inherit from other structs. This is useful for defining a base struct with common attributes.
1182
1239
 
1183
1240
  ```ruby
@@ -1759,3 +1816,7 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/ismasa
1759
1816
  ## License
1760
1817
 
1761
1818
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
1819
+
1820
+ ## Credits
1821
+
1822
+ Created by [Ismael Celis](https://ismaelcelis.com)
data/Rakefile CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
+ require 'docco/tasks'
6
+
5
7
 
6
8
  RSpec::Core::RakeTask.new(:spec)
7
9
 
data/docs/styles.css ADDED
@@ -0,0 +1,540 @@
1
+ /* Reset and Base Styles */
2
+ * {
3
+ margin: 0;
4
+ padding: 0;
5
+ box-sizing: border-box;
6
+ }
7
+
8
+ :root {
9
+ --primary-color: #2563eb;
10
+ --primary-dark: #1e40af;
11
+ --secondary-color: #64748b;
12
+ --bg-color: #f8fafc;
13
+ --sidebar-bg: #1e293b;
14
+ --sidebar-text: #cbd5e1;
15
+ --sidebar-hover: #334155;
16
+ --content-bg: #ffffff;
17
+ --text-color: #1e293b;
18
+ --text-light: #64748b;
19
+ --border-color: #e2e8f0;
20
+ --code-bg: #f1f5f9;
21
+ --code-border: #cbd5e1;
22
+ --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
23
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
24
+ }
25
+
26
+ html {
27
+ scroll-behavior: smooth;
28
+ }
29
+
30
+ body {
31
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
32
+ line-height: 1.6;
33
+ color: var(--text-color);
34
+ background-color: var(--bg-color);
35
+ padding-top: 60px;
36
+ }
37
+
38
+ /* Top Menu Bar */
39
+ .top-menu {
40
+ position: fixed;
41
+ top: 0;
42
+ left: 0;
43
+ right: 0;
44
+ height: 60px;
45
+ background-color: var(--content-bg);
46
+ border-bottom: 1px solid var(--border-color);
47
+ box-shadow: var(--shadow);
48
+ z-index: 1000;
49
+ }
50
+
51
+ .top-menu-content {
52
+ max-width: 100%;
53
+ height: 100%;
54
+ padding: 0 2rem;
55
+ display: flex;
56
+ justify-content: space-between;
57
+ align-items: center;
58
+ }
59
+
60
+ .top-menu-brand {
61
+ display: flex;
62
+ align-items: baseline;
63
+ gap: 0.75rem;
64
+ }
65
+
66
+ .brand-name {
67
+ font-size: 1.5rem;
68
+ font-weight: 700;
69
+ color: var(--text-color);
70
+ }
71
+
72
+ .brand-tagline {
73
+ font-size: 0.875rem;
74
+ color: var(--text-light);
75
+ font-weight: 400;
76
+ }
77
+
78
+ .top-menu .github-link {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 0.5rem;
82
+ padding: 0.5rem 1rem;
83
+ background-color: var(--text-color);
84
+ color: #ffffff;
85
+ border-radius: 6px;
86
+ text-decoration: none;
87
+ font-weight: 600;
88
+ font-size: 0.875rem;
89
+ transition: all 0.2s ease;
90
+ }
91
+
92
+ .top-menu .github-link:hover {
93
+ background-color: #000000;
94
+ text-decoration: none;
95
+ }
96
+
97
+ .top-menu .github-link svg {
98
+ width: 20px;
99
+ height: 20px;
100
+ }
101
+
102
+ /* Layout */
103
+ .container {
104
+ display: flex;
105
+ min-height: calc(100vh - 60px);
106
+ }
107
+
108
+ /* Sidebar */
109
+ .sidebar {
110
+ width: 280px;
111
+ background-color: var(--sidebar-bg);
112
+ color: var(--sidebar-text);
113
+ padding: 2rem 0;
114
+ position: fixed;
115
+ top: 60px;
116
+ height: calc(100vh - 60px);
117
+ overflow-y: auto;
118
+ box-shadow: var(--shadow-lg);
119
+ }
120
+
121
+ .sidebar::-webkit-scrollbar {
122
+ width: 8px;
123
+ }
124
+
125
+ .sidebar::-webkit-scrollbar-track {
126
+ background: var(--sidebar-bg);
127
+ }
128
+
129
+ .sidebar::-webkit-scrollbar-thumb {
130
+ background: var(--sidebar-hover);
131
+ border-radius: 4px;
132
+ }
133
+
134
+ .logo {
135
+ padding: 0 1.5rem 1.5rem;
136
+ border-bottom: 1px solid var(--sidebar-hover);
137
+ margin-bottom: 1.5rem;
138
+ }
139
+
140
+ .logo h2 {
141
+ font-size: 1.75rem;
142
+ color: #ffffff;
143
+ margin-bottom: 0.25rem;
144
+ font-weight: 700;
145
+ }
146
+
147
+ .logo .tagline {
148
+ font-size: 0.875rem;
149
+ color: var(--sidebar-text);
150
+ font-weight: 400;
151
+ }
152
+
153
+ .nav-menu {
154
+ list-style: none;
155
+ padding-left: 0;
156
+ }
157
+
158
+ .nav-menu li {
159
+ margin: 0;
160
+ }
161
+
162
+ .nav-menu a {
163
+ display: block;
164
+ padding: 0.625rem 1.5rem;
165
+ color: var(--sidebar-text);
166
+ text-decoration: none;
167
+ transition: all 0.2s ease;
168
+ font-size: 0.9375rem;
169
+ }
170
+
171
+ .nav-menu li:not(.nav-submenu) > a {
172
+ font-weight: 600;
173
+ margin-top: 0.5rem;
174
+ }
175
+
176
+ .nav-submenu a {
177
+ padding-left: 2.5rem;
178
+ font-size: 0.875rem;
179
+ color: var(--sidebar-text);
180
+ opacity: 0.9;
181
+ }
182
+
183
+ .nav-menu a:hover {
184
+ background-color: var(--sidebar-hover);
185
+ color: #ffffff;
186
+ }
187
+
188
+ .nav-menu a.active {
189
+ background-color: var(--primary-color);
190
+ color: #ffffff;
191
+ border-left: 3px solid #ffffff;
192
+ padding-left: calc(1.5rem - 3px);
193
+ }
194
+
195
+ .nav-submenu a.active {
196
+ padding-left: calc(2.5rem - 3px);
197
+ }
198
+
199
+ /* Accordion Menus */
200
+ .nav-accordion {
201
+ position: relative;
202
+ }
203
+
204
+ .accordion-toggle {
205
+ display: flex;
206
+ justify-content: space-between;
207
+ align-items: center;
208
+ cursor: pointer;
209
+ }
210
+
211
+ .accordion-icon {
212
+ font-size: 0.75rem;
213
+ transition: transform 0.2s ease;
214
+ margin-left: 0.5rem;
215
+ }
216
+
217
+ .nav-accordion.expanded .accordion-icon {
218
+ transform: rotate(-180deg);
219
+ }
220
+
221
+ .accordion-content {
222
+ list-style: none;
223
+ padding-left: 0;
224
+ max-height: 0;
225
+ overflow: hidden;
226
+ transition: max-height 0.3s ease;
227
+ }
228
+
229
+ .nav-accordion.expanded .accordion-content {
230
+ max-height: 500px;
231
+ }
232
+
233
+ .nav-h4 a {
234
+ padding-left: 3.5rem;
235
+ font-size: 0.8125rem;
236
+ color: var(--sidebar-text);
237
+ opacity: 0.85;
238
+ }
239
+
240
+ .nav-h4 a.active {
241
+ padding-left: calc(3.5rem - 3px);
242
+ }
243
+
244
+ /* Main Content */
245
+ .content {
246
+ flex: 1;
247
+ margin-left: 280px;
248
+ padding: 3rem;
249
+ max-width: 1200px;
250
+ width: 79vw;
251
+ }
252
+
253
+ /* Page Header */
254
+ .page-header {
255
+ margin-bottom: 3rem;
256
+ padding-bottom: 2rem;
257
+ border-bottom: 2px solid var(--border-color);
258
+ }
259
+
260
+ .page-header h1 {
261
+ font-size: 3rem;
262
+ font-weight: 800;
263
+ color: var(--text-color);
264
+ margin-bottom: 0.5rem;
265
+ letter-spacing: -0.025em;
266
+ }
267
+
268
+ .page-header .subtitle {
269
+ font-size: 1.25rem;
270
+ color: var(--text-light);
271
+ font-weight: 400;
272
+ }
273
+
274
+ /* Sections */
275
+ .section {
276
+ margin-bottom: 4rem;
277
+ }
278
+
279
+ .section h2 {
280
+ font-size: 2rem;
281
+ font-weight: 700;
282
+ color: var(--text-color);
283
+ margin-bottom: 1.5rem;
284
+ padding-bottom: 0.5rem;
285
+ border-bottom: 2px solid var(--primary-color);
286
+ }
287
+
288
+ .subsection {
289
+ margin-bottom: 2.5rem;
290
+ background-color: var(--content-bg);
291
+ padding: 2rem;
292
+ border-radius: 8px;
293
+ box-shadow: var(--shadow);
294
+ }
295
+
296
+ .subsection h3 {
297
+ font-size: 1.5rem;
298
+ font-weight: 600;
299
+ color: var(--text-color);
300
+ margin-bottom: 1rem;
301
+ }
302
+
303
+ .subsection h4 {
304
+ font-size: 1.25rem;
305
+ font-weight: 600;
306
+ color: var(--text-color);
307
+ margin: 1.5rem 0 1rem;
308
+ }
309
+
310
+ /* Typography */
311
+ p {
312
+ margin-bottom: 1rem;
313
+ line-height: 1.75;
314
+ }
315
+
316
+ a {
317
+ color: var(--primary-color);
318
+ text-decoration: none;
319
+ }
320
+
321
+ a:hover {
322
+ color: var(--primary-dark);
323
+ text-decoration: underline;
324
+ }
325
+
326
+ strong {
327
+ font-weight: 600;
328
+ color: var(--text-color);
329
+ }
330
+
331
+ /* Lists */
332
+ ul, ol {
333
+ margin-bottom: 1rem;
334
+ padding-left: 1.5rem;
335
+ }
336
+
337
+ li {
338
+ margin-bottom: 0.5rem;
339
+ }
340
+
341
+ .feature-list {
342
+ list-style: none;
343
+ padding-left: 0;
344
+ }
345
+
346
+ .feature-list li {
347
+ padding-left: 1.5rem;
348
+ position: relative;
349
+ margin-bottom: 0.75rem;
350
+ }
351
+
352
+ .feature-list li::before {
353
+ content: "→";
354
+ position: absolute;
355
+ left: 0;
356
+ color: var(--primary-color);
357
+ font-weight: bold;
358
+ }
359
+
360
+ /* Code Blocks */
361
+ code {
362
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
363
+ font-size: 0.875em;
364
+ background-color: var(--code-bg);
365
+ padding: 0.2em 0.4em;
366
+ border-radius: 3px;
367
+ border: 1px solid var(--code-border);
368
+ }
369
+
370
+ pre {
371
+ margin: 1.5rem 0;
372
+ padding: 0;
373
+ overflow-x: auto;
374
+ border-radius: 6px;
375
+ box-shadow: var(--shadow);
376
+ background-color: #282c34;
377
+ }
378
+
379
+ pre code {
380
+ display: block;
381
+ padding: 1.25rem;
382
+ background-color: transparent;
383
+ color: inherit;
384
+ border: none;
385
+ border-radius: 6px;
386
+ line-height: 1.6;
387
+ overflow-x: auto;
388
+ }
389
+
390
+ pre code::-webkit-scrollbar {
391
+ height: 8px;
392
+ }
393
+
394
+ pre code::-webkit-scrollbar-track {
395
+ background: var(--sidebar-hover);
396
+ border-radius: 4px;
397
+ }
398
+
399
+ pre code::-webkit-scrollbar-thumb {
400
+ background: var(--sidebar-text);
401
+ border-radius: 4px;
402
+ }
403
+
404
+ /* Images */
405
+ .image-container {
406
+ margin: 2rem 0;
407
+ text-align: center;
408
+ }
409
+
410
+ .image-container img {
411
+ max-width: 100%;
412
+ height: auto;
413
+ }
414
+
415
+ .image-container .caption {
416
+ margin-top: 0.75rem;
417
+ font-size: 0.875rem;
418
+ color: var(--text-light);
419
+ font-style: italic;
420
+ }
421
+
422
+ @media (max-width: 1248px) {
423
+ .content p img {
424
+ width: 64vw;
425
+ height: auto;
426
+ }
427
+ }
428
+
429
+ /* Responsive Design */
430
+ @media (max-width: 1024px) {
431
+ .sidebar {
432
+ width: 240px;
433
+ }
434
+
435
+ .content {
436
+ width: 76vw;
437
+ margin-left: 240px;
438
+ padding: 2rem;
439
+ }
440
+ }
441
+
442
+ @media (max-width: 768px) {
443
+ /* Stack sidebar and content vertically on handhelds */
444
+ .container {
445
+ flex-direction: column;
446
+ }
447
+ .top-menu-content {
448
+ padding: 0 1rem;
449
+ }
450
+
451
+ .brand-tagline {
452
+ display: none;
453
+ }
454
+
455
+ .top-menu .github-link span {
456
+ display: none;
457
+ }
458
+
459
+ .top-menu .github-link {
460
+ padding: 0.5rem;
461
+ }
462
+
463
+ .sidebar {
464
+ display:none;
465
+ }
466
+
467
+ .content {
468
+ margin-left: 0;
469
+ width: 100vw;
470
+ padding: 1.5rem;
471
+ }
472
+
473
+ .content p img {
474
+ width: 78vw;
475
+ height: auto;
476
+ }
477
+ .page-header h1 {
478
+ font-size: 2rem;
479
+ }
480
+
481
+ .page-header .subtitle {
482
+ font-size: 1rem;
483
+ }
484
+
485
+ .section h2 {
486
+ font-size: 1.5rem;
487
+ }
488
+
489
+ .subsection {
490
+ padding: 1.5rem;
491
+ }
492
+
493
+ .subsection h3 {
494
+ font-size: 1.25rem;
495
+ }
496
+
497
+ pre code {
498
+ padding: 1rem;
499
+ font-size: 0.8125rem;
500
+ }
501
+ }
502
+
503
+ /* Smooth Scrolling and Anchor Offset */
504
+ section {
505
+ scroll-margin-top: 5rem;
506
+ }
507
+
508
+ article {
509
+ scroll-margin-top: 5rem;
510
+ }
511
+
512
+ /* Print Styles */
513
+ @media print {
514
+ .top-menu {
515
+ display: none;
516
+ }
517
+
518
+ body {
519
+ padding-top: 0;
520
+ }
521
+
522
+ .sidebar {
523
+ display: none;
524
+ }
525
+
526
+ .content {
527
+ margin-left: 0;
528
+ max-width: 100%;
529
+ }
530
+
531
+ .subsection {
532
+ box-shadow: none;
533
+ border: 1px solid var(--border-color);
534
+ }
535
+
536
+ pre code {
537
+ background-color: var(--code-bg);
538
+ color: var(--text-color);
539
+ }
540
+ }
@@ -54,7 +54,7 @@ module Types
54
54
  image = result.value
55
55
  path = path_for(image.url)
56
56
  File.open(path, 'wb') { |f| f.write(image.io.read) }
57
- result.valid image.with(url: path, io: File.new(path))
57
+ result.valid image.where(url: path, io: File.new(path))
58
58
  end
59
59
 
60
60
  def path_for(url)
@@ -22,7 +22,7 @@ module Types
22
22
  .transform(::Enumerator, &:each)
23
23
 
24
24
  # Turn a string file path into a CSV stream
25
- # ex. csv_enum = StrinToCSV.parse('./files/data.csv') #=> Enumerator
25
+ # ex. csv_enum = StringToCSV.parse('./files/data.csv') #=> Enumerator
26
26
  StringToCSV = OpenFile >> FileToCSV
27
27
  end
28
28
 
@@ -116,7 +116,7 @@ module Plumb
116
116
  attr_reader :errors, :attributes
117
117
 
118
118
  def initialize(attrs = {})
119
- assign_attributes(attrs)
119
+ assign_attributes(self.class._pipeline.parse(attrs))
120
120
  freeze
121
121
  end
122
122
 
@@ -135,8 +135,8 @@ module Plumb
135
135
 
136
136
  def inspect
137
137
  %(#<#{self.class}:#{object_id} [#{valid? ? 'valid' : 'invalid'}] #{attributes.map do |k, v|
138
- [k, v.inspect].join(':')
139
- end.join(' ')}>)
138
+ [k, v.inspect].join(':')
139
+ end.join(' ')}>)
140
140
  end
141
141
 
142
142
  # @return [Hash]
@@ -175,11 +175,53 @@ module Plumb
175
175
  def prepare_attributes(attrs) = attrs
176
176
 
177
177
  module ClassMethods
178
+ def _set_pipeline(pl)
179
+ @_pipeline = pl
180
+ end
181
+
182
+ def _pipeline
183
+ @_pipeline || Plumb::Types::Any
184
+ end
185
+
186
+ # Add a step to the processing pipeline that runs before attribute validation.
187
+ # This allows you to transform or validate the input data before it's assigned to attributes.
188
+ #
189
+ # @param st [Plumb::Step, nil] A step object to add to the pipeline
190
+ # @param block [Proc, nil] A block to use as a step (if st is nil)
191
+ # @return [Class] Returns self for method chaining
192
+ #
193
+ # @example Transform input before validation
194
+ # class Person
195
+ # include Plumb::Attributes
196
+ #
197
+ # step { |result| result.valid(result.value.transform_keys(&:to_sym)) }
198
+ # attribute :name, Types::String
199
+ # end
200
+ #
201
+ # @example Add custom validation
202
+ # class Person
203
+ # include Plumb::Attributes
204
+ #
205
+ # step do |result|
206
+ # if result.value[:name].nil?
207
+ # result.invalid(errors: 'Name is required')
208
+ # else
209
+ # result
210
+ # end
211
+ # end
212
+ # attribute :name, Types::String
213
+ # end
214
+ def step(st = nil, &block)
215
+ @_pipeline = _pipeline >> (st || block)
216
+ self
217
+ end
218
+
178
219
  def _schema
179
220
  @_schema ||= HashClass.new
180
221
  end
181
222
 
182
223
  def inherited(subclass)
224
+ subclass._set_pipeline _pipeline
183
225
  _schema._schema.each do |key, type|
184
226
  subclass.attribute(key, type)
185
227
  end
@@ -220,7 +262,9 @@ module Plumb
220
262
  # attribute(:friends, [Person])
221
263
  #
222
264
  def attribute(name, type = Types::Any, writer: false, &block)
223
- key = Key.wrap(name)
265
+ # Key accepts String or Symbol, with optional '?' suffix for optional keys
266
+ # for Data structs, we always convert to Symbol keys
267
+ key = Key.wrap(name, symbolize: true)
224
268
  name = key.to_sym
225
269
  type = Composable.wrap(type)
226
270
 
@@ -311,16 +311,22 @@ module Plumb
311
311
 
312
312
  # Check attributes of an object against values, using #===
313
313
  # @example
314
- # type = Types::Array.with(size: 1..10)
315
- # type = Types::String.with(bytesize: 1..10)
314
+ # type = Types::Array.where(size: 1..10)
315
+ # type = Types::String.where(bytesize: 1..10)
316
316
  #
317
317
  # @param attrs [Hash]
318
- def with(attrs)
318
+ def where(attrs)
319
319
  attrs.reduce(self) do |t, (name, value)|
320
320
  t >> AttributeValueMatch.new(t, name, value)
321
321
  end
322
322
  end
323
323
 
324
+ # @deprecated User {#where} instead
325
+ def with(...)
326
+ warn 'Composable#with() is deprecated. Use #where() instead. #with is reserved to make copies of Data structs'
327
+ where(...)
328
+ end
329
+
324
330
  # Register a policy for this step.
325
331
  # Mode 1.a: #policy(:name, arg) a single policy with an argument
326
332
  # Mode 1.b: #policy(:name) a single policy without an argument
@@ -113,7 +113,7 @@ module Plumb
113
113
  initial = {}
114
114
  initial = initial.merge(input) if @inclusive
115
115
  output = _schema.each.with_object(initial) do |(key, field), ret|
116
- key_s = key.to_sym
116
+ key_s = key.to_key
117
117
  if input.key?(key_s)
118
118
  r = field.call(field_result.reset(input[key_s]))
119
119
  errors[key_s] = r.errors unless r.valid?
@@ -61,6 +61,13 @@ module Plumb
61
61
  props
62
62
  end
63
63
 
64
+ # Trying to visit the deferred could go into infinite recursion
65
+ # if a type is deferring to itself
66
+ # Not clear what deferred types would mean for JSON Schema anyway.
67
+ on(:deferred) do |node, props|
68
+ props
69
+ end
70
+
64
71
  on(:hash) do |node, props|
65
72
  props.merge(
66
73
  TYPE => 'object',
data/lib/plumb/key.rb CHANGED
@@ -2,28 +2,30 @@
2
2
 
3
3
  module Plumb
4
4
  class Key
5
- OPTIONAL_EXP = /(\w+)(\?)?$/
5
+ # OPTIONAL_EXP = /(\w+)(\?)?$/
6
+ OPTIONAL_EXP = /(?<word>[A-Za-z0-9_$]+)(?<qmark>\?)?/
6
7
 
7
- def self.wrap(key)
8
- key.is_a?(Key) ? key : new(key)
8
+ def self.wrap(key, symbolize: false)
9
+ key.is_a?(Key) ? key : new(key, symbolize:)
9
10
  end
10
11
 
11
- attr_reader :to_sym, :node_name
12
+ attr_reader :to_key, :to_sym, :node_name
12
13
 
13
- def initialize(key, optional: false)
14
- key_s = key.to_s
15
- match = OPTIONAL_EXP.match(key_s)
14
+ def initialize(key, optional: false, symbolize: false)
15
+ key_type = symbolize ? Symbol : key.class
16
+ match = OPTIONAL_EXP.match(key.to_s)
17
+ key = match[:word]
18
+ @to_key = key_type == Symbol ? key.to_sym : key
19
+ @to_sym = @to_key.to_sym
20
+ @optional = !match[:qmark].nil? ? true : optional
16
21
  @node_name = :key
17
- @key = match[1]
18
- @to_sym = @key.to_sym
19
- @optional = !match[2].nil? ? true : optional
20
22
  freeze
21
23
  end
22
24
 
23
- def to_s = @key
25
+ def to_s = @to_key.to_s
24
26
 
25
27
  def hash
26
- @key.hash
28
+ @to_key.hash
27
29
  end
28
30
 
29
31
  def eql?(other)
@@ -35,7 +37,7 @@ module Plumb
35
37
  end
36
38
 
37
39
  def inspect
38
- "#{@key}#{'?' if @optional}"
40
+ "#{@to_key}#{'?' if @optional}"
39
41
  end
40
42
  end
41
43
  end
@@ -41,9 +41,8 @@ module Plumb
41
41
  @type = type
42
42
  @children = [type].freeze
43
43
  @around_blocks = self.class.around_blocks.dup
44
- return unless block_given?
45
44
 
46
- configure(&setup)
45
+ configure(&setup) if block_given?
47
46
  freeze if freeze_after
48
47
  end
49
48
 
data/lib/plumb/schema.rb CHANGED
@@ -178,8 +178,8 @@ module Plumb
178
178
  self
179
179
  end
180
180
 
181
- def with(...)
182
- @_type = @_type.with(...)
181
+ def where(...)
182
+ @_type = @_type.where(...)
183
183
  self
184
184
  end
185
185
 
data/lib/plumb/types.rb CHANGED
@@ -155,6 +155,22 @@ module Plumb
155
155
  Date = Any[::Date]
156
156
  Time = Any[::Time]
157
157
 
158
+ # A type that recursively converts string keys to symbols in nested hashes.
159
+ # This is commonly used for normalizing payload data in commands and events.
160
+ #
161
+ # @example Simple hash symbolization
162
+ # SymbolizedHash.parse({ 'name' => 'John' }) # => { name: 'John' }
163
+ # @example Nested hash symbolization
164
+ # SymbolizedHash.parse({ 'user' => { 'name' => 'John' } }) # => { user: { name: 'John' } }
165
+ # @example Mixed types preserved
166
+ # SymbolizedHash.parse({ 'count' => 1, 'active' => true }) # => { count: 1, active: true }
167
+ SymbolizedHash = Hash[
168
+ # String keys are converted to symbols, existing symbols are preserved
169
+ (Symbol | String.transform(::Symbol, &:to_sym)),
170
+ # Hash values are recursively symbolized, other types pass through unchanged
171
+ Any.defer { SymbolizedHash } | Any
172
+ ]
173
+
158
174
  module UUID
159
175
  V4 = String[/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i].as_node(:uuid)
160
176
  end
data/lib/plumb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plumb
4
- VERSION = '0.0.14'
4
+ VERSION = '0.0.16'
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plumb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.14
4
+ version: 0.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-10-03 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: bigdecimal
@@ -38,7 +37,7 @@ dependencies:
38
37
  - - ">="
39
38
  - !ruby/object:Gem::Version
40
39
  version: '0'
41
- description: Data validation and transformation library.
40
+ description: Data structures, validation, coercion and processing toolkit for Ruby
42
41
  email:
43
42
  - ismaelct@gmail.com
44
43
  executables: []
@@ -54,6 +53,7 @@ files:
54
53
  - bench/compare_parametric_struct.rb
55
54
  - bench/parametric_schema.rb
56
55
  - bench/plumb_hash.rb
56
+ - docs/styles.css
57
57
  - examples/command_objects.rb
58
58
  - examples/concurrent_downloads.rb
59
59
  - examples/csv_stream.rb
@@ -97,11 +97,11 @@ files:
97
97
  - lib/plumb/value_class.rb
98
98
  - lib/plumb/version.rb
99
99
  - lib/plumb/visitor_handlers.rb
100
- homepage: https://github.com/ismasan/plumb
100
+ homepage: https://ismasan.github.io/plumb
101
101
  licenses:
102
102
  - MIT
103
- metadata: {}
104
- post_install_message:
103
+ metadata:
104
+ source_code_uri: https://github.com/ismasan/plumb
105
105
  rdoc_options: []
106
106
  require_paths:
107
107
  - lib
@@ -116,8 +116,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
116
116
  - !ruby/object:Gem::Version
117
117
  version: '0'
118
118
  requirements: []
119
- rubygems_version: 3.5.21
120
- signing_key:
119
+ rubygems_version: 3.6.9
121
120
  specification_version: 4
122
121
  summary: Data validation and transformation library.
123
122
  test_files: []