plumb 0.0.3 → 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 +391 -52
- data/examples/concurrent_downloads.rb +3 -3
- data/examples/env_config.rb +2 -2
- data/examples/event_registry.rb +120 -0
- 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} +61 -28
- data/lib/plumb/decorator.rb +57 -0
- data/lib/plumb/deferred.rb +1 -1
- data/lib/plumb/hash_class.rb +19 -8
- data/lib/plumb/hash_map.rb +8 -6
- data/lib/plumb/interface_class.rb +6 -2
- data/lib/plumb/json_schema_visitor.rb +50 -32
- data/lib/plumb/match_class.rb +4 -3
- data/lib/plumb/metadata.rb +5 -1
- data/lib/plumb/metadata_visitor.rb +13 -42
- data/lib/plumb/not.rb +4 -3
- data/lib/plumb/or.rb +10 -4
- data/lib/plumb/pipeline.rb +6 -5
- data/lib/plumb/policy.rb +10 -3
- data/lib/plumb/schema.rb +11 -10
- 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 +6 -1
- data/lib/plumb/value_class.rb +4 -3
- data/lib/plumb/version.rb +1 -1
- data/lib/plumb/visitor_handlers.rb +6 -0
- data/lib/plumb.rb +11 -5
- metadata +6 -3
| @@ -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 |  | 
    
        data/examples/env_config.rb
    CHANGED
    
    | @@ -32,10 +32,10 @@ module Types | |
| 32 32 | 
             
              end
         | 
| 33 33 |  | 
| 34 34 | 
             
              # A dummy S3 client
         | 
| 35 | 
            -
              S3Client = Data.define(:bucket, :region)
         | 
| 35 | 
            +
              S3Client = ::Data.define(:bucket, :region)
         | 
| 36 36 |  | 
| 37 37 | 
             
              # A dummy SFTP client
         | 
| 38 | 
            -
              SFTPClient = Data.define(:host, :username, :password)
         | 
| 38 | 
            +
              SFTPClient = ::Data.define(:host, :username, :password)
         | 
| 39 39 |  | 
| 40 40 | 
             
              # Map these fields to an S3 client
         | 
| 41 41 | 
             
              S3Config = Types::Hash[
         | 
| @@ -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/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 |  | 
| @@ -23,10 +23,6 @@ module Plumb | |
| 23 23 | 
             
              NOOP = ->(result) { result }
         | 
| 24 24 |  | 
| 25 25 | 
             
              module Callable
         | 
| 26 | 
            -
                def metadata
         | 
| 27 | 
            -
                  MetadataVisitor.call(self)
         | 
| 28 | 
            -
                end
         | 
| 29 | 
            -
             | 
| 30 26 | 
             
                def resolve(value = Undefined)
         | 
| 31 27 | 
             
                  call(Result.wrap(value))
         | 
| 32 28 | 
             
                end
         | 
| @@ -43,9 +39,15 @@ module Plumb | |
| 43 39 | 
             
                end
         | 
| 44 40 | 
             
              end
         | 
| 45 41 |  | 
| 46 | 
            -
              module  | 
| 47 | 
            -
             | 
| 42 | 
            +
              # This module gets included by Composable,
         | 
| 43 | 
            +
              # but only when Composable is `included` in classes, not `extended`.
         | 
| 44 | 
            +
              # The rule of this module is to assign a name to constants that point to Composable instances.
         | 
| 45 | 
            +
              module Naming
         | 
| 46 | 
            +
                attr_reader :name
         | 
| 48 47 |  | 
| 48 | 
            +
                # When including this module,
         | 
| 49 | 
            +
                # define a #node_name method on the Composable instance
         | 
| 50 | 
            +
                # #node_name is used by Visitors to determine the type of node.
         | 
| 49 51 | 
             
                def self.included(base)
         | 
| 50 52 | 
             
                  nname = base.name.split('::').last
         | 
| 51 53 | 
             
                  nname.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
         | 
| @@ -55,20 +57,6 @@ module Plumb | |
| 55 57 | 
             
                  base.define_method(:node_name) { nname }
         | 
| 56 58 | 
             
                end
         | 
| 57 59 |  | 
| 58 | 
            -
                def self.wrap(callable)
         | 
| 59 | 
            -
                  if callable.is_a?(Steppable)
         | 
| 60 | 
            -
                    callable
         | 
| 61 | 
            -
                  elsif callable.is_a?(::Hash)
         | 
| 62 | 
            -
                    HashClass.new(schema: callable)
         | 
| 63 | 
            -
                  elsif callable.respond_to?(:call)
         | 
| 64 | 
            -
                    Step.new(callable)
         | 
| 65 | 
            -
                  else
         | 
| 66 | 
            -
                    MatchClass.new(callable)
         | 
| 67 | 
            -
                  end
         | 
| 68 | 
            -
                end
         | 
| 69 | 
            -
             | 
| 70 | 
            -
                attr_reader :name
         | 
| 71 | 
            -
             | 
| 72 60 | 
             
                class Name
         | 
| 73 61 | 
             
                  def initialize(name)
         | 
| 74 62 | 
             
                    @name = name
         | 
| @@ -94,17 +82,42 @@ module Plumb | |
| 94 82 | 
             
                def inspect = name.to_s
         | 
| 95 83 |  | 
| 96 84 | 
             
                def node_name = self.class.name.split('::').last.to_sym
         | 
| 85 | 
            +
              end
         | 
| 86 | 
            +
             | 
| 87 | 
            +
              #  Composable mixes in composition methods to classes.
         | 
| 88 | 
            +
              # such as #>>, #|, #not, and others.
         | 
| 89 | 
            +
              # Any Composable class can participate in Plumb compositions.
         | 
| 90 | 
            +
              module Composable
         | 
| 91 | 
            +
                include Callable
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                # This only runs when including Composable,
         | 
| 94 | 
            +
                # not extending classes with it.
         | 
| 95 | 
            +
                def self.included(base)
         | 
| 96 | 
            +
                  base.send(:include, Naming)
         | 
| 97 | 
            +
                end
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                def self.wrap(callable)
         | 
| 100 | 
            +
                  if callable.is_a?(Composable)
         | 
| 101 | 
            +
                    callable
         | 
| 102 | 
            +
                  elsif callable.is_a?(::Hash)
         | 
| 103 | 
            +
                    HashClass.new(schema: callable)
         | 
| 104 | 
            +
                  elsif callable.respond_to?(:call)
         | 
| 105 | 
            +
                    Step.new(callable)
         | 
| 106 | 
            +
                  else
         | 
| 107 | 
            +
                    MatchClass.new(callable)
         | 
| 108 | 
            +
                  end
         | 
| 109 | 
            +
                end
         | 
| 97 110 |  | 
| 98 111 | 
             
                def defer(definition = nil, &block)
         | 
| 99 112 | 
             
                  Deferred.new(definition || block)
         | 
| 100 113 | 
             
                end
         | 
| 101 114 |  | 
| 102 115 | 
             
                def >>(other)
         | 
| 103 | 
            -
                  And.new(self,  | 
| 116 | 
            +
                  And.new(self, Composable.wrap(other))
         | 
| 104 117 | 
             
                end
         | 
| 105 118 |  | 
| 106 119 | 
             
                def |(other)
         | 
| 107 | 
            -
                  Or.new(self,  | 
| 120 | 
            +
                  Or.new(self, Composable.wrap(other))
         | 
| 108 121 | 
             
                end
         | 
| 109 122 |  | 
| 110 123 | 
             
                def transform(target_type, callable = nil, &block)
         | 
| @@ -115,8 +128,12 @@ module Plumb | |
| 115 128 | 
             
                  self >> MatchClass.new(block, error: errors, label: errors)
         | 
| 116 129 | 
             
                end
         | 
| 117 130 |  | 
| 118 | 
            -
                def  | 
| 119 | 
            -
                   | 
| 131 | 
            +
                def metadata(data = Undefined)
         | 
| 132 | 
            +
                  if data == Undefined
         | 
| 133 | 
            +
                    MetadataVisitor.call(self)
         | 
| 134 | 
            +
                  else
         | 
| 135 | 
            +
                    self >> Metadata.new(data)
         | 
| 136 | 
            +
                  end
         | 
| 120 137 | 
             
                end
         | 
| 121 138 |  | 
| 122 139 | 
             
                def not(other = self)
         | 
| @@ -138,7 +155,7 @@ module Plumb | |
| 138 155 | 
             
                def [](val) = match(val)
         | 
| 139 156 |  | 
| 140 157 | 
             
                class Node
         | 
| 141 | 
            -
                  include  | 
| 158 | 
            +
                  include Composable
         | 
| 142 159 |  | 
| 143 160 | 
             
                  attr_reader :node_name, :type, :attributes
         | 
| 144 161 |  | 
| @@ -168,11 +185,15 @@ module Plumb | |
| 168 185 | 
             
                    types = Array(metadata[:type]).uniq
         | 
| 169 186 |  | 
| 170 187 | 
             
                    bargs = [self]
         | 
| 171 | 
            -
                     | 
| 188 | 
            +
                    arg = Undefined
         | 
| 189 | 
            +
                    if rest.any?
         | 
| 190 | 
            +
                      bargs << rest.first
         | 
| 191 | 
            +
                      arg = rest.first
         | 
| 192 | 
            +
                    end
         | 
| 172 193 | 
             
                    block = Plumb.policies.get(types, name)
         | 
| 173 194 | 
             
                    pol = block.call(*bargs, &blk)
         | 
| 174 195 |  | 
| 175 | 
            -
                    Policy.new(name,  | 
| 196 | 
            +
                    Policy.new(name, arg, pol)
         | 
| 176 197 | 
             
                  in [::Hash => opts] # #policy(p1: value, p2: value)
         | 
| 177 198 | 
             
                    opts.reduce(self) { |step, (name, value)| step.policy(name, value) }
         | 
| 178 199 | 
             
                  else
         | 
| @@ -182,13 +203,19 @@ module Plumb | |
| 182 203 |  | 
| 183 204 | 
             
                def ===(other)
         | 
| 184 205 | 
             
                  case other
         | 
| 185 | 
            -
                  when  | 
| 206 | 
            +
                  when Composable
         | 
| 186 207 | 
             
                    other == self
         | 
| 187 208 | 
             
                  else
         | 
| 188 209 | 
             
                    resolve(other).valid?
         | 
| 189 210 | 
             
                  end
         | 
| 190 211 | 
             
                end
         | 
| 191 212 |  | 
| 213 | 
            +
                def ==(other)
         | 
| 214 | 
            +
                  other.is_a?(self.class) && other.children == children
         | 
| 215 | 
            +
                end
         | 
| 216 | 
            +
             | 
| 217 | 
            +
                def children = BLANK_ARRAY
         | 
| 218 | 
            +
             | 
| 192 219 | 
             
                def build(cns, factory_method = :new, &block)
         | 
| 193 220 | 
             
                  self >> Build.new(cns, factory_method:, &block)
         | 
| 194 221 | 
             
                end
         | 
| @@ -201,6 +228,12 @@ module Plumb | |
| 201 228 | 
             
                  inspect
         | 
| 202 229 | 
             
                end
         | 
| 203 230 |  | 
| 231 | 
            +
                # @option root [Boolean] whether to include JSON Schema $schema property
         | 
| 232 | 
            +
                # @return [Hash]
         | 
| 233 | 
            +
                def to_json_schema(root: false)
         | 
| 234 | 
            +
                  JSONSchemaVisitor.call(self, root:)
         | 
| 235 | 
            +
                end
         | 
| 236 | 
            +
             | 
| 204 237 | 
             
                # Build a step that will invoke one or more methods on the value.
         | 
| 205 238 | 
             
                # Ex 1: Types::String.invoke(:downcase)
         | 
| 206 239 | 
             
                # Ex 2: Types::Array.invoke(:[], 1)
         |