Giter Site home page Giter Site logo

portrayal's Introduction

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.

portrayal's People

Contributors

maxim avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

Forkers

going-dev

portrayal's Issues

Allow changing inherited class in blocks

I need help deciding this. Once this is settled, portrayal will be ready to become 1.0.0.

The problem

Portrayal supports block class definition.

class ApplicationStruct
  extend Portrayal
end

class ApplicationForm < ApplicationStruct
  keyword :method
  keyword :action
end

class NewCompanyForm < ApplicationForm
  keyword :name

  keyword :address do # <--- THIS RIGHT HERE
    keyword :street
    keyword :city
    keyword :country
    keyword :postal_code
  end
end

This will create a class for you: NewCompanyForm::Address. Problem is, this class automatically inherits ApplicationForm (parent of the surrounding class). This is supposed to be a useful feature, because in most cases you want this.

Unfortunately, in this example it's undesirable, because you don't want method and action to be added to the Address subsection of the form.

I'd like to figure out the best way to avoid including method and action into Address.

Option 1

One way is to do nothing, and you just have to use regular class syntax to work around this:

class NewCompanyForm < ApplicationForm
  keyword :name
  keyword :address

  class Address < ApplicationStruct
    keyword :street
    keyword :city
    keyword :country
    keyword :postal_code
  end
end

Option 2

We can add an inherit option. If you don't provide it, it would work the same as today. If you provide it, you could do this:

  keyword :address, inherit: false do
    # address fields
  end

Or you could inherit something else:

  keyword :address, inherit: 'AnotherClass' do
    # address fields
  end

The issue with this approach is that, what if AnotherClass doesn't have extend Portrayal in it? Should portrayal detect it, and in that case extend the Address for you? Otherwise you won't be able to declare keywords in it.

Another issue is that this is just some weird extra stuff you have to learn.

Option 3

Do nothing and encourage the use of modules instead of subclassing.

class ApplicationStruct
  extend Portrayal
end

module Form
  def self.extended(base)
    base.keyword :method
    base.keyword :action
  end
end

class NewCompanyForm < ApplicationStruct
  extend Form

  keyword :address do
    # … address keywords …
  end
end

Now only NewCompanyForm gets method and action, and Address inherits ApplicationStruct.

The problem with this approach is that now you have to remember to do 2 things (inherit and extend) to make a form object.

Option 4?

Maybe there's a more elegant solution? Any suggestions are welcome.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.