plumb 0.0.2 → 0.0.4
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/README.md +636 -129
- data/examples/concurrent_downloads.rb +3 -3
- data/examples/env_config.rb +122 -0
- data/examples/event_registry.rb +120 -0
- data/examples/weekdays.rb +1 -1
- data/lib/plumb/and.rb +4 -3
- data/lib/plumb/any_class.rb +4 -4
- data/lib/plumb/array_class.rb +8 -5
- data/lib/plumb/attributes.rb +262 -0
- data/lib/plumb/build.rb +4 -3
- data/lib/plumb/{steppable.rb → composable.rb} +85 -67
- data/lib/plumb/decorator.rb +57 -0
- data/lib/plumb/deferred.rb +1 -1
- data/lib/plumb/hash_class.rb +20 -11
- data/lib/plumb/hash_map.rb +8 -6
- data/lib/plumb/interface_class.rb +6 -2
- data/lib/plumb/json_schema_visitor.rb +97 -36
- data/lib/plumb/match_class.rb +7 -7
- data/lib/plumb/metadata.rb +5 -1
- data/lib/plumb/metadata_visitor.rb +18 -38
- data/lib/plumb/not.rb +4 -3
- data/lib/plumb/or.rb +10 -4
- data/lib/plumb/pipeline.rb +6 -5
- data/lib/plumb/policies.rb +81 -0
- data/lib/plumb/policy.rb +38 -0
- data/lib/plumb/schema.rb +13 -12
- data/lib/plumb/static_class.rb +4 -3
- data/lib/plumb/step.rb +4 -3
- data/lib/plumb/stream_class.rb +8 -7
- data/lib/plumb/tagged_hash.rb +10 -10
- data/lib/plumb/transform.rb +4 -3
- data/lib/plumb/tuple_class.rb +8 -8
- data/lib/plumb/type_registry.rb +5 -2
- data/lib/plumb/types.rb +119 -23
- data/lib/plumb/value_class.rb +4 -3
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +12 -1
- data/lib/plumb.rb +59 -2
- metadata +12 -7
- data/lib/plumb/rules.rb +0 -102
@@ -15,8 +15,8 @@ module Types
|
|
15
15
|
# Turn a string into an URI
|
16
16
|
URL = String[/^https?:/].build(::URI, :parse)
|
17
17
|
|
18
|
-
# a Struct to
|
19
|
-
Image = Data.define(:url, :io)
|
18
|
+
# a Struct to hold image data
|
19
|
+
Image = ::Data.define(:url, :io)
|
20
20
|
|
21
21
|
# A (naive) step to download files from the internet
|
22
22
|
# and return an Image struct.
|
@@ -38,7 +38,7 @@ module Types
|
|
38
38
|
# Wrap the #reader and #wruter methods into Plumb steps
|
39
39
|
# A step only needs #call(Result) => Result to work in a pipeline,
|
40
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
|
41
|
+
# as well as all the other helper methods provided by the Composable module.
|
42
42
|
def read = Plumb::Step.new(method(:reader))
|
43
43
|
def write = Plumb::Step.new(method(:writer))
|
44
44
|
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bundler/setup'
|
4
|
+
require 'plumb'
|
5
|
+
require 'debug'
|
6
|
+
|
7
|
+
# Types and pipelines for defining and parsing ENV configuration
|
8
|
+
# Run with `bundle exec ruby examples/env_config.rb`
|
9
|
+
#
|
10
|
+
# Given an ENV with variables to configure one of three types of network/IO clients,
|
11
|
+
# parse, validate and coerce the configuration into the appropriate client object.
|
12
|
+
# ENV vars are expected to be prefixed with `FILE_UPLOAD_`, followed by the client type.
|
13
|
+
# See example usage at the bottom of this file.
|
14
|
+
module Types
|
15
|
+
include Plumb::Types
|
16
|
+
|
17
|
+
# Define a custom policy to extract a string using a regular expression.
|
18
|
+
# Policies are factories for custom type compositions.
|
19
|
+
#
|
20
|
+
# Usage:
|
21
|
+
# type = Types::String.extract(/^FOO_(\w+)$/).invoke(:[], 1)
|
22
|
+
# type.parse('FOO_BAR') # => 'BAR'
|
23
|
+
#
|
24
|
+
Plumb.policy :extract, for_type: ::String, helper: true do |type, regex|
|
25
|
+
type >> lambda do |result|
|
26
|
+
match = result.value.match(regex)
|
27
|
+
return result.invalid(errors: "does not match #{regex.source}") if match.nil?
|
28
|
+
return result.invalid(errors: 'no captures') if match.captures.none?
|
29
|
+
|
30
|
+
result.valid(match)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# A dummy S3 client
|
35
|
+
S3Client = ::Data.define(:bucket, :region)
|
36
|
+
|
37
|
+
# A dummy SFTP client
|
38
|
+
SFTPClient = ::Data.define(:host, :username, :password)
|
39
|
+
|
40
|
+
# Map these fields to an S3 client
|
41
|
+
S3Config = Types::Hash[
|
42
|
+
transport: 's3',
|
43
|
+
bucket: String.present,
|
44
|
+
region: String.options(%w[us-west-1 us-west-2 us-east-1])
|
45
|
+
].invoke(:except, :transport).build(S3Client) { |h| S3Client.new(**h) }
|
46
|
+
|
47
|
+
# Map these fields to an SFTP client
|
48
|
+
SFTPConfig = Types::Hash[
|
49
|
+
transport: 'sftp',
|
50
|
+
host: String.present,
|
51
|
+
username: String.present,
|
52
|
+
password: String.present,
|
53
|
+
].invoke(:except, :transport).build(SFTPClient) { |h| SFTPClient.new(**h) }
|
54
|
+
|
55
|
+
# Map these fields to a File client
|
56
|
+
FileConfig = Types::Hash[
|
57
|
+
transport: 'file',
|
58
|
+
path: String.present,
|
59
|
+
].invoke(:[], :path).build(::File)
|
60
|
+
|
61
|
+
# Take a string such as 'FILE_UPLOAD_BUCKET', extract the `BUCKET` bit,
|
62
|
+
# downcase and symbolize it.
|
63
|
+
FileUploadKey = String.extract(/^FILE_UPLOAD_(\w+)$/).invoke(:[], 1).invoke(%i[downcase to_sym])
|
64
|
+
|
65
|
+
# Filter a Hash (or ENV) to keys that match the FILE_UPLOAD_* pattern
|
66
|
+
FileUploadHash = Types::Hash[FileUploadKey, Any].filtered
|
67
|
+
|
68
|
+
# Pipeline syntax to put the program together
|
69
|
+
FileUploadClientFromENV = Any.pipeline do |pl|
|
70
|
+
# 1. Accept any Hash-like object (e.g. ENV)
|
71
|
+
pl.step Types::Interface[:[], :key?, :each, :to_h]
|
72
|
+
|
73
|
+
# 2. Transform it to a Hash
|
74
|
+
pl.step Any.transform(::Hash, &:to_h)
|
75
|
+
|
76
|
+
# 3. Filter keys with FILE_UPLOAD_* prefix
|
77
|
+
pl.step FileUploadHash
|
78
|
+
|
79
|
+
# 4. Parse the configuration for a particular client object
|
80
|
+
pl.step(S3Config | SFTPConfig | FileConfig)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Ex.
|
84
|
+
# client = FileUploadClientFromENV.parse(ENV) # SFTP, S3 or File client
|
85
|
+
|
86
|
+
# The above is the same as:
|
87
|
+
#
|
88
|
+
# FileUploadClientFromENV = Types::Interface[:[], :key?, :each, :to_h] \
|
89
|
+
# .transform(::Hash, &:to_h) >> \
|
90
|
+
# Types::Hash[FileUploadKey, Any].filtered >> \
|
91
|
+
# (S3Config | SFTPConfig | FileConfig)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Simulated ENV hashes. Just use ::ENV for the real thing.
|
95
|
+
ENV_S3 = {
|
96
|
+
'FILE_UPLOAD_TRANSPORT' => 's3',
|
97
|
+
'FILE_UPLOAD_BUCKET' => 'my-bucket',
|
98
|
+
'FILE_UPLOAD_REGION' => 'us-west-2',
|
99
|
+
'SOMETHING_ELSE' => 'ignored'
|
100
|
+
}.freeze
|
101
|
+
# => S3Client.new(bucket: 'my-bucket', region: 'us-west-2')
|
102
|
+
|
103
|
+
ENV_SFTP = {
|
104
|
+
'FILE_UPLOAD_TRANSPORT' => 'sftp',
|
105
|
+
'FILE_UPLOAD_HOST' => 'sftp.example.com',
|
106
|
+
'FILE_UPLOAD_USERNAME' => 'username',
|
107
|
+
'FILE_UPLOAD_PASSWORD' => 'password',
|
108
|
+
'SOMETHING_ELSE' => 'ignored'
|
109
|
+
}.freeze
|
110
|
+
# => SFTPClient.new(host: 'sftp.example.com', username: 'username', password: 'password')
|
111
|
+
|
112
|
+
ENV_FILE = {
|
113
|
+
'FILE_UPLOAD_TRANSPORT' => 'file',
|
114
|
+
'FILE_UPLOAD_PATH' => File.join('examples', 'programmers.csv')
|
115
|
+
}.freeze
|
116
|
+
|
117
|
+
p Types::FileUploadClientFromENV.parse(ENV_S3) # #<data Types::S3Client bucket="my-bucket", region="us-west-2">
|
118
|
+
p Types::FileUploadClientFromENV.parse(ENV_SFTP) # #<data Types::SFTPClient host="sftp.example.com", username="username", password="password">
|
119
|
+
p Types::FileUploadClientFromENV.parse(ENV_FILE) # #<File path="examples/programmers.csv">
|
120
|
+
|
121
|
+
# Or with invalid or missing configuration
|
122
|
+
# p Types::FileUploadClientFromENV.parse({}) # raises error
|
@@ -0,0 +1,120 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'plumb'
|
4
|
+
require 'time'
|
5
|
+
require 'uri'
|
6
|
+
require 'securerandom'
|
7
|
+
require 'debug'
|
8
|
+
|
9
|
+
# Bring Plumb into our own namespace
|
10
|
+
# and define some basic types
|
11
|
+
module Types
|
12
|
+
include Plumb::Types
|
13
|
+
|
14
|
+
# Turn an ISO8601 sring into a Time object
|
15
|
+
ISOTime = String.build(::Time, :parse)
|
16
|
+
|
17
|
+
# A type that can be a Time object or an ISO8601 string >> Time
|
18
|
+
Time = Any[::Time] | ISOTime
|
19
|
+
|
20
|
+
# A UUID string
|
21
|
+
UUID = String[/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i]
|
22
|
+
|
23
|
+
# A UUID string, or generate a new one
|
24
|
+
AutoUUID = UUID.default { SecureRandom.uuid }
|
25
|
+
|
26
|
+
Email = String[URI::MailTo::EMAIL_REGEXP]
|
27
|
+
end
|
28
|
+
|
29
|
+
# A superclass and registry to define event types
|
30
|
+
# for example for an event-driven or event-sourced system.
|
31
|
+
# All events have an "envelope" set of attributes,
|
32
|
+
# including unique ID, stream_id, type, timestamp, causation ID,
|
33
|
+
# event subclasses have a type string (ex. 'users.name.updated') and an optional payload
|
34
|
+
# This class provides a `.define` method to create new event types with a type and optional payload struct,
|
35
|
+
# a `.from` method to instantiate the correct subclass from a hash, ex. when deserializing from JSON or a web request.
|
36
|
+
# and a `#follow` method to produce new events based on a previous event's envelope, where the #causation_id and #correlation_id
|
37
|
+
# are set to the parent event
|
38
|
+
# @example
|
39
|
+
#
|
40
|
+
# # Define event struct with type and payload
|
41
|
+
# UserCreated = Event.define('users.created') do
|
42
|
+
# attribute :name, Types::String
|
43
|
+
# attribute :email, Types::Email
|
44
|
+
# end
|
45
|
+
#
|
46
|
+
# # Instantiate a full event with .new
|
47
|
+
# user_created = UserCreated.new(stream_id: 'user-1', payload: { name: 'Joe', email: '...' })
|
48
|
+
#
|
49
|
+
# # Use the `.from(Hash) => Event` factory to lookup event class by `type` and produce the right instance
|
50
|
+
# user_created = Event.from(type: 'users.created', stream_id: 'user-1', payload: { name: 'Joe', email: '...' })
|
51
|
+
#
|
52
|
+
# # Use #follow(payload Hash) => Event to produce events following a command or parent event
|
53
|
+
# create_user = CreateUser.new(...)
|
54
|
+
# user_created = create_user.follow(UserCreated, name: 'Joe', email: '...')
|
55
|
+
# user_created.causation_id == create_user.id
|
56
|
+
# user_created.correlation_id == create_user.correlation_id
|
57
|
+
# user_created.stream_id == create_user.stream_id
|
58
|
+
#
|
59
|
+
# ## JSON Schemas
|
60
|
+
# Plumb data structs support `.to_json_schema`, to you can document all events in the registry with something like
|
61
|
+
#
|
62
|
+
# Event.registry.values.map(&:to_json_schema)
|
63
|
+
#
|
64
|
+
class Event < Types::Data
|
65
|
+
attribute :id, Types::AutoUUID
|
66
|
+
attribute :stream_id, Types::String.present
|
67
|
+
attribute :type, Types::String
|
68
|
+
attribute(:created_at, Types::Time.default { ::Time.now })
|
69
|
+
attribute? :causation_id, Types::UUID
|
70
|
+
attribute? :correlation_id, Types::UUID
|
71
|
+
attribute :payload, Types::Static[nil]
|
72
|
+
|
73
|
+
def self.registry
|
74
|
+
@registry ||= {}
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.define(type_str, &payload_block)
|
78
|
+
type_str.freeze unless type_str.frozen?
|
79
|
+
registry[type_str] = Class.new(self) do
|
80
|
+
attribute :type, Types::Static[type_str]
|
81
|
+
attribute :payload, &payload_block if block_given?
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def self.from(attrs)
|
86
|
+
klass = registry[attrs[:type]]
|
87
|
+
raise ArgumentError, "Unknown event type: #{attrs[:type]}" unless klass
|
88
|
+
|
89
|
+
klass.new(attrs)
|
90
|
+
end
|
91
|
+
|
92
|
+
def follow(event_class, payload_attrs = nil)
|
93
|
+
attrs = { stream_id:, causation_id: id, correlation_id: }
|
94
|
+
attrs[:payload] = payload_attrs if payload_attrs
|
95
|
+
event_class.new(attrs)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# Example command and events for a simple event-sourced system
|
100
|
+
#
|
101
|
+
# ## Commands
|
102
|
+
# CreateUser = Event.define('users.create') do
|
103
|
+
# attribute :name, Types::String.present
|
104
|
+
# attribute :email, Types::Email
|
105
|
+
# end
|
106
|
+
#
|
107
|
+
# UpdateUserName = Event.define('users.update_name') do
|
108
|
+
# attribute :name, Types::String.present
|
109
|
+
# end
|
110
|
+
#
|
111
|
+
# ## Events
|
112
|
+
# UserCreated = Event.define('users.created') do
|
113
|
+
# attribute :name, Types::String
|
114
|
+
# attribute :email, Types::Email
|
115
|
+
# end
|
116
|
+
#
|
117
|
+
# UserNameUpdated = Event.define('users.name_updated') do
|
118
|
+
# attribute :name, Types::String
|
119
|
+
# end
|
120
|
+
# debugger
|
data/examples/weekdays.rb
CHANGED
@@ -41,7 +41,7 @@ module Types
|
|
41
41
|
# Ex. [1, 2, 3, 4, 5, 6, 7], [1, 2, 4], ['monday', 'tuesday', 'wednesday', 7]
|
42
42
|
# Turn day names into numbers, and sort the array.
|
43
43
|
Week = Array[DayNameOrNumber]
|
44
|
-
.
|
44
|
+
.policy(size: 1..7)
|
45
45
|
.check('repeated days') { |days| days.uniq.size == days.size }
|
46
46
|
.transform(::Array, &:sort)
|
47
47
|
end
|
data/lib/plumb/and.rb
CHANGED
@@ -1,16 +1,17 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require 'plumb/
|
3
|
+
require 'plumb/composable'
|
4
4
|
|
5
5
|
module Plumb
|
6
6
|
class And
|
7
|
-
include
|
7
|
+
include Composable
|
8
8
|
|
9
|
-
attr_reader :
|
9
|
+
attr_reader :children
|
10
10
|
|
11
11
|
def initialize(left, right)
|
12
12
|
@left = left
|
13
13
|
@right = right
|
14
|
+
@children = [left, right].freeze
|
14
15
|
freeze
|
15
16
|
end
|
16
17
|
|
data/lib/plumb/any_class.rb
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require 'plumb/
|
3
|
+
require 'plumb/composable'
|
4
4
|
|
5
5
|
module Plumb
|
6
6
|
class AnyClass
|
7
|
-
include
|
7
|
+
include Composable
|
8
8
|
|
9
|
-
def |(other) =
|
10
|
-
def >>(other) =
|
9
|
+
def |(other) = Composable.wrap(other)
|
10
|
+
def >>(other) = Composable.wrap(other)
|
11
11
|
|
12
12
|
# Any.default(value) must trigger default when value is Undefined
|
13
13
|
def default(...)
|
data/lib/plumb/array_class.rb
CHANGED
@@ -1,18 +1,19 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'concurrent'
|
4
|
-
require 'plumb/
|
4
|
+
require 'plumb/composable'
|
5
5
|
require 'plumb/result'
|
6
6
|
require 'plumb/stream_class'
|
7
7
|
|
8
8
|
module Plumb
|
9
9
|
class ArrayClass
|
10
|
-
include
|
10
|
+
include Composable
|
11
11
|
|
12
|
-
attr_reader :
|
12
|
+
attr_reader :children
|
13
13
|
|
14
14
|
def initialize(element_type: Types::Any)
|
15
|
-
@element_type =
|
15
|
+
@element_type = Composable.wrap(element_type)
|
16
|
+
@children = [@element_type].freeze
|
16
17
|
|
17
18
|
freeze
|
18
19
|
end
|
@@ -47,11 +48,13 @@ module Plumb
|
|
47
48
|
values, errors = map_array_elements(result.value)
|
48
49
|
return result.valid(values) unless errors.any?
|
49
50
|
|
50
|
-
result.invalid(errors:)
|
51
|
+
result.invalid(values, errors:)
|
51
52
|
end
|
52
53
|
|
53
54
|
private
|
54
55
|
|
56
|
+
attr_reader :element_type
|
57
|
+
|
55
58
|
def _inspect
|
56
59
|
%(Array[#{element_type}])
|
57
60
|
end
|
@@ -0,0 +1,262 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Plumb
|
4
|
+
module Attributes
|
5
|
+
# A module that provides a simple way to define a struct-like class with
|
6
|
+
# attributes that are type-checked on initialization.
|
7
|
+
#
|
8
|
+
# @example
|
9
|
+
# class Person
|
10
|
+
# include Plumb::Attributes
|
11
|
+
#
|
12
|
+
# attribute :name, Types::String
|
13
|
+
# attribute :age, Types::Integer[18..]
|
14
|
+
# end
|
15
|
+
#
|
16
|
+
# person = Person.new(name: 'Jane', age: 20)
|
17
|
+
# person.valid? # => true
|
18
|
+
# person.errors # => {}
|
19
|
+
# person.name # => 'Jane'
|
20
|
+
#
|
21
|
+
# It supports nested attributes:
|
22
|
+
#
|
23
|
+
# @example
|
24
|
+
# class Person
|
25
|
+
# include Plumb::Attributes
|
26
|
+
#
|
27
|
+
# attribute :friend do
|
28
|
+
# attribute :name, String
|
29
|
+
# end
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
# person = Person.new(friend: { name: 'John' })
|
33
|
+
#
|
34
|
+
# Or arrays of nested attributes:
|
35
|
+
#
|
36
|
+
# @example
|
37
|
+
# class Person
|
38
|
+
# include Plumb::Attributes
|
39
|
+
#
|
40
|
+
# attribute :friends, Types::Array do
|
41
|
+
# atrribute :name, String
|
42
|
+
# end
|
43
|
+
# end
|
44
|
+
#
|
45
|
+
# person = Person.new(friends: [{ name: 'John' }])
|
46
|
+
#
|
47
|
+
# Or use struct classes defined separately:
|
48
|
+
#
|
49
|
+
# @example
|
50
|
+
# class Company
|
51
|
+
# include Plumb::Attributes
|
52
|
+
# attribute :name, String
|
53
|
+
# end
|
54
|
+
#
|
55
|
+
# class Person
|
56
|
+
# include Plumb::Attributes
|
57
|
+
#
|
58
|
+
# # Single nested struct
|
59
|
+
# attribute :company, Company
|
60
|
+
#
|
61
|
+
# # Array of nested structs
|
62
|
+
# attribute :companies, Types::Array[Company]
|
63
|
+
# end
|
64
|
+
#
|
65
|
+
# Arrays and other types support composition and helpers. Ex. `#default`.
|
66
|
+
#
|
67
|
+
# attribute :companies, Types::Array[Company].default([].freeze)
|
68
|
+
#
|
69
|
+
# Passing a named struct class AND a block will subclass the struct and extend it with new attributes:
|
70
|
+
#
|
71
|
+
# attribute :company, Company do
|
72
|
+
# attribute :address, String
|
73
|
+
# end
|
74
|
+
#
|
75
|
+
# The same works with arrays:
|
76
|
+
#
|
77
|
+
# attribute :companies, Types::Array[Company] do
|
78
|
+
# attribute :address, String
|
79
|
+
# end
|
80
|
+
#
|
81
|
+
# Note that this does NOT work with union'd or piped structs.
|
82
|
+
#
|
83
|
+
# attribute :company, Company | Person do
|
84
|
+
#
|
85
|
+
# ## Optional Attributes
|
86
|
+
# Using `attribute?` allows for optional attributes. If the attribute is not present, it will be set to `Undefined`.
|
87
|
+
#
|
88
|
+
# attribute? :company, Company
|
89
|
+
#
|
90
|
+
# ## Struct Inheritance
|
91
|
+
# Structs can inherit from other structs. This is useful for defining a base struct with common attributes.
|
92
|
+
#
|
93
|
+
# class BasePerson
|
94
|
+
# include Plumb::Attributes
|
95
|
+
#
|
96
|
+
# attribute :name, String
|
97
|
+
# end
|
98
|
+
#
|
99
|
+
# class Person < BasePerson
|
100
|
+
# attribute :age, Integer
|
101
|
+
# end
|
102
|
+
#
|
103
|
+
# ## [] Syntax
|
104
|
+
#
|
105
|
+
# The `[]` syntax can be used to define a struct in a single line.
|
106
|
+
# Like Plumb::Types::Hash, suffixing a key with `?` makes it optional.
|
107
|
+
#
|
108
|
+
# Person = Data[name: String, age?: Integer]
|
109
|
+
# person = Person.new(name: 'Jane')
|
110
|
+
#
|
111
|
+
def self.included(base)
|
112
|
+
base.send(:extend, ClassMethods)
|
113
|
+
end
|
114
|
+
|
115
|
+
attr_reader :errors, :attributes
|
116
|
+
|
117
|
+
def initialize(attrs = {})
|
118
|
+
assign_attributes(attrs)
|
119
|
+
end
|
120
|
+
|
121
|
+
def ==(other)
|
122
|
+
other.is_a?(self.class) && other.attributes == attributes
|
123
|
+
end
|
124
|
+
|
125
|
+
# @return [Boolean]
|
126
|
+
def valid? = !errors || errors.none?
|
127
|
+
|
128
|
+
# @param attrs [Hash]
|
129
|
+
# @return [Plumb::Attributes]
|
130
|
+
def with(attrs = BLANK_HASH)
|
131
|
+
self.class.new(attributes.merge(attrs))
|
132
|
+
end
|
133
|
+
|
134
|
+
def inspect
|
135
|
+
%(#<#{self.class}:#{object_id} [#{valid? ? 'valid' : 'invalid'}] #{attributes.map do |k, v|
|
136
|
+
[k, v.inspect].join(':')
|
137
|
+
end.join(' ')}>)
|
138
|
+
end
|
139
|
+
|
140
|
+
# @return [Hash]
|
141
|
+
def to_h
|
142
|
+
attributes.transform_values do |value|
|
143
|
+
case value
|
144
|
+
when ::Array
|
145
|
+
value.map { |v| v.respond_to?(:to_h) ? v.to_h : v }
|
146
|
+
else
|
147
|
+
value.respond_to?(:to_h) ? value.to_h : value
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def deconstruct(...) = to_h.values.deconstruct(...)
|
153
|
+
def deconstruct_keys(...) = to_h.deconstruct_keys(...)
|
154
|
+
|
155
|
+
private
|
156
|
+
|
157
|
+
def assign_attributes(attrs = BLANK_HASH)
|
158
|
+
raise ArgumentError, 'Must be a Hash of attributes' unless attrs.is_a?(::Hash)
|
159
|
+
|
160
|
+
@errors = BLANK_HASH
|
161
|
+
result = self.class._schema.resolve(attrs)
|
162
|
+
@attributes = result.value
|
163
|
+
@errors = result.errors unless result.valid?
|
164
|
+
end
|
165
|
+
|
166
|
+
module ClassMethods
|
167
|
+
def _schema
|
168
|
+
@_schema ||= HashClass.new
|
169
|
+
end
|
170
|
+
|
171
|
+
def inherited(subclass)
|
172
|
+
_schema._schema.each do |key, type|
|
173
|
+
subclass.attribute(key, type)
|
174
|
+
end
|
175
|
+
super
|
176
|
+
end
|
177
|
+
|
178
|
+
# The Plumb::Step interface
|
179
|
+
# @param result [Plumb::Result::Valid]
|
180
|
+
# @return [Plumb::Result::Valid, Plumb::Result::Invalid]
|
181
|
+
def call(result)
|
182
|
+
return result if result.value.is_a?(self)
|
183
|
+
return result.invalid(errors: ['Must be a Hash of attributes']) unless result.value.is_a?(Hash)
|
184
|
+
|
185
|
+
instance = new(result.value)
|
186
|
+
instance.valid? ? result.valid(instance) : result.invalid(instance, errors: instance.errors)
|
187
|
+
end
|
188
|
+
|
189
|
+
# Person = Data[:name => String, :age => Integer, title?: String]
|
190
|
+
def [](type_specs)
|
191
|
+
klass = Class.new(self)
|
192
|
+
type_specs.each do |key, type|
|
193
|
+
klass.attribute(key, type)
|
194
|
+
end
|
195
|
+
klass
|
196
|
+
end
|
197
|
+
|
198
|
+
# node name for visitors
|
199
|
+
def node_name = :data
|
200
|
+
|
201
|
+
# attribute(:friend) { attribute(:name, String) }
|
202
|
+
# attribute(:friend, MyStruct) { attribute(:name, String) }
|
203
|
+
# attribute(:name, String)
|
204
|
+
# attribute(:friends, Types::Array) { attribute(:name, String) }
|
205
|
+
# attribute(:friends, Types::Array) # same as Types::Array[Types::Any]
|
206
|
+
# attribute(:friends, Types::Array[Person])
|
207
|
+
#
|
208
|
+
def attribute(name, type = Types::Any, &block)
|
209
|
+
key = Key.wrap(name)
|
210
|
+
name = key.to_sym
|
211
|
+
type = Composable.wrap(type)
|
212
|
+
if block_given? # :foo, Array[Data] or :foo, Struct
|
213
|
+
type = Types::Data if type == Types::Any
|
214
|
+
type = Plumb.decorate(type) do |node|
|
215
|
+
if node.is_a?(Plumb::ArrayClass)
|
216
|
+
child = node.children.first
|
217
|
+
child = Types::Data if child == Types::Any
|
218
|
+
Types::Array[build_nested(name, child, &block)]
|
219
|
+
elsif node.is_a?(Plumb::Step)
|
220
|
+
build_nested(name, node, &block)
|
221
|
+
elsif node.is_a?(Class) && node <= Plumb::Attributes
|
222
|
+
build_nested(name, node, &block)
|
223
|
+
else
|
224
|
+
node
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
@_schema = _schema + { key => type }
|
230
|
+
define_method(name) { @attributes[name] }
|
231
|
+
end
|
232
|
+
|
233
|
+
def attribute?(name, *args, &block)
|
234
|
+
attribute(Key.new(name, optional: true), *args, &block)
|
235
|
+
end
|
236
|
+
|
237
|
+
def build_nested(name, node, &block)
|
238
|
+
if node.is_a?(Class) && node <= Plumb::Attributes
|
239
|
+
sub = Class.new(node)
|
240
|
+
sub.instance_exec(&block)
|
241
|
+
__set_nested_class__(name, sub)
|
242
|
+
return Composable.wrap(sub)
|
243
|
+
end
|
244
|
+
|
245
|
+
return node unless node.is_a?(Plumb::Step)
|
246
|
+
|
247
|
+
child = node.children.first
|
248
|
+
return node unless child <= Plumb::Attributes
|
249
|
+
|
250
|
+
sub = Class.new(child)
|
251
|
+
sub.instance_exec(&block)
|
252
|
+
__set_nested_class__(name, sub)
|
253
|
+
Composable.wrap(sub)
|
254
|
+
end
|
255
|
+
|
256
|
+
def __set_nested_class__(name, klass)
|
257
|
+
name = name.to_s.split('_').map(&:capitalize).join.sub(/s$/, '')
|
258
|
+
const_set(name, klass) unless const_defined?(name)
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
data/lib/plumb/build.rb
CHANGED
@@ -1,16 +1,17 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require 'plumb/
|
3
|
+
require 'plumb/composable'
|
4
4
|
|
5
5
|
module Plumb
|
6
6
|
class Build
|
7
|
-
include
|
7
|
+
include Composable
|
8
8
|
|
9
|
-
attr_reader :
|
9
|
+
attr_reader :children
|
10
10
|
|
11
11
|
def initialize(type, factory_method: :new, &block)
|
12
12
|
@type = type
|
13
13
|
@block = block || ->(value) { type.send(factory_method, value) }
|
14
|
+
@children = [type].freeze
|
14
15
|
freeze
|
15
16
|
end
|
16
17
|
|