Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Types::Data structs #13

Merged
merged 40 commits into from
Aug 5, 2024
Merged

Types::Data structs #13

merged 40 commits into from
Aug 5, 2024

Conversation

ismasan
Copy link
Owner

@ismasan ismasan commented Aug 2, 2024

Types::Data

Types:: Data provides a superclass to define inmutable structs or value objects with typed / coercible attributes.

class Person < Types::Data
  attribute :name, Types::String.present
  attribute :age, Types::Integer
end

These classes can be instantiated normally, and expose #valid? and #errors

person = Person.new(name: 'Joe')
person.name # 'Joe'
person.valid? # false
person.errors[:age] # 'must be an integer'

Note that these instances cannot be mutated (there's no attribute setters), but they can be copied with partial attributes with #with

another_person = person.with(age: 20)

It supports nested attributes:

class Person < Types::Data
  attribute :friend do
    attribute :name, String
  end
  
  # Custom methods like any other class
  def friend_name = friend.name
end

person = Person.new(friend: { name: 'John' })
person.friend_count # 1

Or arrays of nested attributes:

class Person < Types::Data
  attribute :friends, Types::Array do
    atrribute :name, String
  end
end

person = Person.new(friends: [{ name: 'John' }])

Or use struct classes defined separately:

class Company < Types::Data
  attribute :name, String
end

class Person < Types::Data
  # Single nested struct
  attribute :company, Company

  # Array of nested structs
  attribute :companies, Types::Array[Company]
end

Arrays and other types support composition and helpers. Ex. #default.

attribute :companies, Types::Array[Company].default([].freeze)

Passing a named struct class AND a block will subclass the struct and extend it with new attributes:

attribute :company, Company do
  attribute :address, String
end

The same works with arrays:

attribute :companies, Types::Array[Company] do
  attribute :address, String
end

Note that this does NOT work with union'd or piped structs.

attribute :company, Company | Person do

Optional Attributes

Using attribute? allows for optional attributes. If the attribute is not present, it will be set to nil.

attribute? :company, Company

Struct Inheritance

Structs can inherit from other structs. This is useful for defining a base struct with common attributes.

class BasePerson < Types::Data
  attribute :name, String
end

class Person < BasePerson
  attribute :age, Integer
end

Equality with #==

#== is implemented to compare attributes, recursively.

person1 = Person.new(name: 'Joe', age: 20)
person2 = Person.new(name: 'Joe', age: 20)
person1 == person2 # true

[] Syntax

The [] syntax can be used as a shorthand.
Like Plumb::Types::Hash``, suffixing a key with ?` makes it optional.

Person = Types::Data[name: String, age?: Integer]
person = Person.new(name: 'Jane')

This syntax creates subclasses too.

# Subclass Person with and redefine the :age type.
Adult = Person[age?: Types::Integer[18..]]

Struct composition

Types::Data supports all the composition operators and helpers.

Note however that, once you wrap a struct in a composition, you can't instantiate it with .new anymore (but you can still use #parse or #resolve like any other Plumb type).

Person = Types::Data[name: String]
Animal = Types::Data[species: String]
# Compose with |
Being = Person | Animal
Being.parse(name: 'Joe') # <Person [valid] name: 'Joe'>

# Compose with other types
Beings = Types::Array[Person | Animal]

# Default
Payload = Types::Hash[
  being: Being.default(Person.new(name: 'Joe Bloggs'))
]

Recursive struct definitions

You can use #defer like with any other type definitions.

Person = Types::Data[
  name: String,
  friend?: Types::Any.defer { Person }
]

person = Person.new(name: 'Joe', friend: { name: 'Joan'})
person.friend.name # 'joan'
person.friend.friend # nil

How

This PR introduces a Plumb::Attributes module that provides the .attribute class macro.
It also makes it possible to make classes composable with extend Plumb::Composable (as opposed to include Plumb::Composable for other core Plumb types.

So, Types::Data is this:

class Data
  extend Plumb::Composable
  include Plumb::Attributes
end

This means you can have classes that are composable but don't have the .attribute macro, and classes that have typed attributes, but do not include the composable methods (Plumb will still wrap them and make them composable, as long as they implement .call(result) => result).

This refactor makes it possible to have other custom classes act like composable types without wrapping:

class User
  extend Plumb::Composable

  attr_reader :name

  def initialize(name)
    @name = name
  end

  # the core Plumb Step interface
  # Expect a User instance, or string and return a new User with that name
  def self.call(result)
    return result if result.value.is_a?(self)
    return result.invalid(errors: 'expected a string') unless result.value.is_a?(String)

    result.valid new(result.value)
  end
end

# User can now be composed with other types
CoercibleUser = (User | (Types::String >> User)).default(User.new('Anon'))
CoercibleUser.parse 'Joe' # <User @name="Joe">
CoercibleUser.parse User.new('Joe') # <User @name="Joe">
CoercibleUser.parse() # <User @name="Anon">
CoercibleUser.parse(1) # raises Plumb::TypeError

This PR also adds a #to_json_schema method to all Composables, including struct classes.

@ismasan ismasan changed the title Struct attributes Types::Data structs Aug 2, 2024
@ismasan ismasan merged commit f833fee into main Aug 5, 2024
1 check passed
@ismasan ismasan deleted the struct_attributes branch August 5, 2024 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant