[go: up one dir, main page]

Skip to content

maxim/portrayal

Repository files navigation

Gem Version RSpec

Portrayal

Inspired by:

Portrayal is a minimalist gem (~110 loc, no dependencies) for building struct-like classes. It provides a small yet powerful step up from plain ruby with its one and only keyword method.

class Person < MySuperClass
  extend Portrayal

  keyword :name
  keyword :age, default: nil
  keyword :favorite_fruit, default: 'feijoa'

  keyword :address do
    keyword :street
    keyword :city

    def text
      "#{street}, #{city}"
    end
  end
end

When you call keyword:

  • It defines an attr_reader
  • It defines a protected attr_writer
  • It defines initialize
  • It defines == and eql?
  • It defines #hash for hash equality
  • It defines #dup and #clone that propagate to all keyword values
  • It defines #freeze that propagates to all keyword values
  • It defines #deconstruct and #deconstruct_keys for pattern matching
  • It creates a nested class when you supply a block
  • It inherits parent's superclass when creating a nested class

The code above produces almost exactly the following ruby. There's a lot of boilerplate here we didn't have to type.

class Person < MySuperClass
  attr_accessor :name, :age, :favorite_fruit, :address
  protected :name=, :age=, :favorite_fruit=, :address=

  def initialize(name:, age: nil, favorite_fruit: 'feijoa', address:)
    @name = name
    @age = age
    @favorite_fruit = favorite_fruit
    @address = address
  end

  def ==(other)
    self.class == other.class &&
      @name == other.instance_variable_get('@name') &&
      @age == other.instance_variable_get('@age') &&
      @favorite_fruit == other.instance_variable_get('@favorite_fruit') &&
      @address == other.instance_variable_get('@address')
  end

  alias eql? ==

  def hash
    [ self.class, { name: @name, age: @age, favorite_fruit: @favorite_fruit, address: @address } ].hash
  end

  def freeze
    @name.freeze
    @age.freeze
    @favorite_fruit.freeze
    @address.freeze
    super
  end

  def deconstruct
    [ name, age, favorite_fruit, address ]
  end

  def deconstruct_keys(*)
    { name: name, age: age, favorite_fruit: favorite_fruit, address: address }
  end

  def initialize_dup(source)
    @name = source.instance_variable_get('@name').dup
    @age = source.instance_variable_get('@age').dup
    @favorite_fruit = source.instance_variable_get('@favorite_fruit').dup
    @address = source.instance_variable_get('@address').dup
    super
  end

  def initialize_clone(source)
    @name = source.instance_variable_get('@name').clone
    @age = source.instance_variable_get('@age').clone
    @favorite_fruit = source.instance_variable_get('@favorite_fruit').clone
    @address = source.instance_variable_get('@address').clone
    super
  end

  class Address < MySuperClass
    attr_accessor :street, :city
    protected :street=, :city=

    def initialize(street:, city:)
      @street = street
      @city = city
    end

    def text
      "#{street}, #{city}"
    end

    def ==(other)
      self.class == other.class && 
        @street == other.instance_variable_get('@street') &&
        @city == other.instance_variable_get('@city')
    end

    alias eql? ==

    def hash
      [ self.class, { street: @street, city: @city } ].hash
    end

    def freeze
      @street.freeze
      @city.freeze
      super
    end

    def deconstruct
      [ street, city ]
    end

    def deconstruct_keys(*)
      { street: street, city: city }
    end

    def initialize_dup(source)
      @street = source.instance_variable_get('@street').dup
      @city = source.instance_variable_get('@city').dup
      super
    end

    def initialize_clone(source)
      @street = source.instance_variable_get('@street').clone
      @city = source.instance_variable_get('@city').clone
      super
    end
  end
end

Installation

Add this line to your application's Gemfile:

gem 'portrayal'

And then execute:

$ bundle

Or install it yourself as:

$ gem install portrayal

Usage

The recommended way of using this gem is to build your own superclass extended with Portrayal. For example, if you're in Rails, you could do something like this:

class ApplicationStruct
  include ActiveModel::Model
  extend Portrayal
end

Now you can inherit it when building domain objects.

class Address < ApplicationStruct
  keyword :street
  keyword :city
  keyword :postcode
  keyword :country, default: nil
end

Possible use cases for these objects include, but are not limited to:

  • Decorator/presenter objects
  • Tableless models
  • Objects serializable for 3rd party APIs
  • Objects serializable for React components

Defaults

When specifying default, there's a difference between procs and lambda.

keyword :foo, default: proc { 2 + 2 } # => Will call this proc and return 4
keyword :foo, default: -> { 2 + 2 }   # => Will return this lambda itself

Any other value works as normal.

keyword :foo, default: 4

Default procs

Default procs are executed as though they were called in your class's initialize, so they have access to other keywords and instance methods.

keyword :name
keyword :greeting, default: proc { "Hello, #{name}" }

Defaults can also use results of other defaults.

keyword :four,  default: proc { 2 + 2 }
keyword :eight, default: proc { four * 2 }

Or instance methods of the class.

keyword :id, default: proc { generate_id }

private

def generate_id
  SecureRandom.alphanumeric
end

Note: The order in which you declare keywords matters when specifying defaults that depend on other keywords. This will not have the desired effect:

keyword :greeting, default: proc { "Hello, #{name}" }
keyword :name

Nested Classes

When you pass a block to a keyword, it creates a nested class named after camelized keyword name.

class Person
  extend Portrayal

  keyword :address do
    keyword :street
  end
end

The above block created class Person::Address.

If you want to change the name of the created class, use the option define.

class Person
  extend Portrayal

  keyword :visited_countries, define: 'Country' do
    keyword :name
  end
end

This defines Person::Country, while the accessor remains visited_countries.

Subclassing

Portrayal supports subclassing.

class Person
  extend Portrayal
  
  class << self
    def from_contact(contact)
      new name:    contact.full_name,
          address: contact.address.to_s,
          email:   contact.email
    end
  end
  
  keyword :name
  keyword :address
  keyword :email, default: nil
end
class Employee < Person
  keyword :employee_id
  keyword :email, default: proc { "#{employee_id}@example.com" }
end

Now when you call Employee.new it will accept keywords of both superclass and subclass. You can also see how email's default is overridden in the subclass.

However, if you try calling Employee.from_contact(contact) it will error out, because that constructor doesn't set an employee_id required in the subclass. You can remedy that with a small change.

    def from_contact(contact, **kwargs)
      new name:    contact.full_name,
          address: contact.address.to_s,
          email:   contact.email,
          **kwargs
    end

If you add **kwargs to Person.from_contact and pass them through to new, then you are now able to call Employee.from_contact(contact, employee_id: 'some_id')

Pattern Matching

If your Ruby has pattern matching, you can pattern match portrayal objects. Both array- and hash-style matching are supported.

class Point
  extend Portrayal

  keyword :x
  keyword :y
end

point = Point.new(x: 5, y: 10)

case point
in 5, 10
  'matched'
else
  'did not match'
end # => "matched"

case point
in x:, y: 10
  'matched'
else
  'did not match'
end # => "matched"

Introspection

Every class that extends Portrayal receives a method called portrayal. This method is a schema of your object with some additional helpers.

portrayal.keywords

Get all keyword names.

Address.portrayal.keywords # => [:street, :city, :postcode, :country]

portrayal.attributes(object)

Get all names + values as a hash.

address = Address.new(street: '34th st', city: 'NYC', postcode: '10001', country: 'USA')
Address.portrayal.attributes(address) # => {street: '34th st', city: 'NYC', postcode: '10001', country: 'USA'}

portrayal.schema

Get everything portrayal knows about your keywords in one hash.

Address.portrayal.schema # => {:street=>nil, :city=>nil, :postcode=>nil, :country=><Portrayal::Default @value=nil @callable=false>}

Philosophy

Portrayal steps back from things like type enforcement, coercion, and writer methods in favor of read-only structs, and good old constructors.

Good Constructors

Since a portrayal object is read-only (nothing stops you from adding writers, but I will personally frown upon you), you must set all its values in a constructor. This is a good thing, because it lets us study, coerce, and validate all the passed-in arguments in one convenient place. We're assured that once instantiated, the object is valid. And of course we can have multiple constructors if needed. They serve as adapters for different kinds of input.

class Address < ApplicationStruct
  class << self
    def from_form(params)
      raise ArgumentError, 'invalid postcode' if params[:postcode] !~ /\A\d+\z/

      new \
        street:   params[:street].to_s,
        city:     params[:city].to_s,
        postcode: params[:postcode].to_i,
        country:  params[:country] || 'USA'
    end

    def from_some_service_api_object(object)
      new \
        street:   "#{object.houseNumber} #{object.streetName}",
        city:     object.city,
        postcode: object.zipCode,
        counry:   object.countryName != '' ? object.countryName : 'USA'
    end
  end

  keyword :street
  keyword :city
  keyword :postcode
  keyword :country, default: nil
end

Good constructors can depend on one another to successively convert arguments into keywords. This is similar to how in functional languages one can use recursion and pattern matching.

class Email < ApplicationStruct
  class << self
    # Extract parts of an email from JSON, and kick it over to from_parts.
    def from_publishing_service_json(json)
      subject, header, body, footer = *JSON.parse(json)
      from_parts(subject: subject, header: header, body: body, footer: footer)
    end

    # Combine parts into the final keywords: subject and body.
    def from_parts(subject:, header:, body:, footer:)
      new(subject: subject, body: "#{header}#{body}#{footer}")
    end
  end

  keyword :subject
  keyword :body
end

If these contructors need more space to grow in complexity, they can be extracted into their own files.

address/
  from_form_constructor.rb
address.rb
class Address < ApplicationStruct
  class << self
    def from_form(params)
      self::FromFormConstructor.new(params).call
    end
  end

  keyword :street
  keyword :city
  keyword :postcode
  keyword :country, default: nil
end

If a particular constructor doesn't belong on your object (i.e. a 3rd party module is responsible for parsing its own data and producing your object) — you don't need to have a special constructor. Remember that each portrayal object comes with .new, which accepts every keyword directly. Let the module do all the parsing on its side and call .new with final values.

No Reinventing The Wheel

Portrayal leans on Ruby's built-in features as much as possible. For initialize and default values it generates standard ruby keyword arguments. You can see all the code portrayal generates for your objects by running YourClass.portrayal.render_module_code.

[1] pry(main)> puts Address.portrayal.render_module_code
attr_accessor :street, :city, :postcode, :country
protected :street=, :city=, :postcode=, :country=
def initialize(street:, city:, postcode:, country: self.class.portrayal.schema[:country]); @street = street.is_a?(::Portrayal::Default) ? street.(self) : street; @city = city.is_a?(::Portrayal::Default) ? city.(self) : city; @postcode = postcode.is_a?(::Portrayal::Default) ? postcode.(self) : postcode; @country = country.is_a?(::Portrayal::Default) ? country.(self) : country end
def hash; [self.class, {street: @street, city: @city, postcode: @postcode, country: @country}].hash end
def ==(other); self.class == other.class && @street == other.instance_variable_get('@street') && @city == other.instance_variable_get('@city') && @postcode == other.instance_variable_get('@postcode') && @country == other.instance_variable_get('@country') end
alias eql? ==
def freeze; @street.freeze; @city.freeze; @postcode.freeze; @country.freeze; super end
def initialize_dup(src); @street = src.instance_variable_get('@street').dup; @city = src.instance_variable_get('@city').dup; @postcode = src.instance_variable_get('@postcode').dup; @country = src.instance_variable_get('@country').dup; super end
def initialize_clone(src); @street = src.instance_variable_get('@street').clone; @city = src.instance_variable_get('@city').clone; @postcode = src.instance_variable_get('@postcode').clone; @country = src.instance_variable_get('@country').clone; super end
def deconstruct
  public_syms = [:street, :city, :postcode, :country].select { |s| self.class.public_method_defined?(s) }
  public_syms.map { |s| public_send(s) }
end
def deconstruct_keys(keys)
  filtered_keys = [:street, :city, :postcode, :country].select {|s| self.class.public_method_defined?(s) }
  filtered_keys &= keys if Array === keys
  Hash[filtered_keys.map { |k| [k, public_send(k)] }]
end

Implementation decisions

Here are some key architectural decisions that took a lot of thinking. If you have good counter-arguments please make an issue, or contact me on mastodon / twitter.

  1. Why do methods #==, #eql?, #hash rely on @instance @variables instead of calling reader methods?
    Portrayal makes a careful assumption on what most people would expect from object equality: a comparison of type and runtime state (which is what instance variables are). Portrayal avoids comparing object structure and method return values, because it's too situational whether they should participate in equality or not. If you have such a situation, you're welcome to redefine == in your class.
  2. Why do methods clone and dup copy @instance @variables instead of calling reader methods?
    As with the reason for ==, when we clone an object, we want to clone its type and runtime state. Not the artifacts of its structure. It's too presumptious for a clone to assume that method outputs are authoritative. If objects are written deterministically, then by cloning their inner runtime state we should get the same reader method outputs anyway. If you are doing something else, you're welcome to redefine initialize_clone/initialize_dup in your class.
  3. Why does pattern matching (deconstruct/deconstruct_keys) call reader methods rather than reading @instance @variables?
    Unlike equality or object replication, in case of pattern matching we're no longer trying to figure out object's identity, rather we are now an external caller working directly with the values that an object exposes. That's why portrayal lets pattern matching depend on reader methods that get to decide how to expose data outwardly, while making a conscious effort to exclude private and protected readers. You're welcome to override deconstruct and deconstruct_keys in your class if you'd like to do something different.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rspec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/maxim/portrayal. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the Apache License Version 2.0.

Code of Conduct

Everyone interacting in the Portrayal project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.