{"id":13878214,"url":"https://github.com/maxim/portrayal","last_synced_at":"2025-04-04T19:10:02.737Z","repository":{"id":53590385,"uuid":"190693278","full_name":"maxim/portrayal","owner":"maxim","description":"A minimal builder for struct-like classes in Ruby","archived":false,"fork":false,"pushed_at":"2025-01-28T19:00:29.000Z","size":118,"stargazers_count":77,"open_issues_count":1,"forks_count":2,"subscribers_count":5,"default_branch":"main","last_synced_at":"2025-03-28T18:14:40.838Z","etag":null,"topics":["decorator","domain-object","presenter","ruby","serializable","struct"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/maxim.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2019-06-07T05:35:27.000Z","updated_at":"2025-03-24T21:14:37.000Z","dependencies_parsed_at":"2024-11-10T01:12:37.443Z","dependency_job_id":"f8cea6b5-86fb-499f-9a4b-8fc126df9f4c","html_url":"https://github.com/maxim/portrayal","commit_stats":null,"previous_names":[],"tags_count":11,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/maxim%2Fportrayal","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/maxim%2Fportrayal/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/maxim%2Fportrayal/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/maxim%2Fportrayal/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/maxim","download_url":"https://codeload.github.com/maxim/portrayal/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":247234921,"owners_count":20905854,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["decorator","domain-object","presenter","ruby","serializable","struct"],"created_at":"2024-08-06T08:01:42.926Z","updated_at":"2025-04-04T19:10:02.714Z","avatar_url":"https://github.com/maxim.png","language":"Ruby","funding_links":[],"categories":["Ruby"],"sub_categories":[],"readme":"[![Gem Version](https://badge.fury.io/rb/portrayal.svg)](https://badge.fury.io/rb/portrayal) ![RSpec](https://github.com/maxim/portrayal/workflows/RSpec/badge.svg)\n\n# Portrayal\n\nInspired by:\n\n  - Andrew Kozin's [dry-initializer](https://github.com/dry-rb/dry-initializer)\n  - Piotr Solnica's [virtus](https://github.com/solnic/virtus)\n  - Everything [Michel Martens](https://github.com/soveran)\n\nPortrayal is a minimalist gem (~122 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.\n\n```ruby\nclass Person \u003c MySuperClass\n  extend Portrayal\n\n  keyword :name\n  keyword :age, default: nil\n  keyword :favorite_fruit, default: 'feijoa'\n\n  keyword :address do\n    keyword :street\n    keyword :city\n\n    def text\n      \"#{street}, #{city}\"\n    end\n  end\nend\n```\n\nWhen you call `keyword`:\n\n* It defines an `attr_reader`\n* It defines a protected `attr_writer`\n* It defines `initialize`\n* It defines `==` and `eql?`\n* It defines `#hash` for hash equality\n* It defines `#dup` and `#clone` that propagate to all keyword values\n* It defines `#freeze` that propagates to all keyword values\n* It defines `#deconstruct` and `#deconstruct_keys` for pattern matching\n* It creates a nested class when you supply a block\n* It inherits parent's superclass when creating a nested class\n\nThe code above produces almost exactly the following ruby. There's a lot of boilerplate here we didn't have to type.\n\n```ruby\nclass Person \u003c MySuperClass\n  attr_accessor :name, :age, :favorite_fruit, :address\n  protected :name=, :age=, :favorite_fruit=, :address=\n\n  def initialize(name:, age: nil, favorite_fruit: 'feijoa', address:)\n    @name = name\n    @age = age\n    @favorite_fruit = favorite_fruit\n    @address = address\n  end\n\n  def ==(other)\n    self.class == other.class \u0026\u0026\n      @name == other.instance_variable_get('@name') \u0026\u0026\n      @age == other.instance_variable_get('@age') \u0026\u0026\n      @favorite_fruit == other.instance_variable_get('@favorite_fruit') \u0026\u0026\n      @address == other.instance_variable_get('@address')\n  end\n\n  alias eql? ==\n\n  def hash\n    [ self.class, { name: @name, age: @age, favorite_fruit: @favorite_fruit, address: @address } ].hash\n  end\n\n  def freeze\n    @name.freeze\n    @age.freeze\n    @favorite_fruit.freeze\n    @address.freeze\n    super\n  end\n\n  def deconstruct\n    [ name, age, favorite_fruit, address ]\n  end\n\n  def deconstruct_keys(*)\n    { name: name, age: age, favorite_fruit: favorite_fruit, address: address }\n  end\n\n  def initialize_dup(source)\n    @name = source.instance_variable_get('@name').dup\n    @age = source.instance_variable_get('@age').dup\n    @favorite_fruit = source.instance_variable_get('@favorite_fruit').dup\n    @address = source.instance_variable_get('@address').dup\n    super\n  end\n\n  def initialize_clone(source)\n    @name = source.instance_variable_get('@name').clone\n    @age = source.instance_variable_get('@age').clone\n    @favorite_fruit = source.instance_variable_get('@favorite_fruit').clone\n    @address = source.instance_variable_get('@address').clone\n    super\n  end\n\n  class Address \u003c MySuperClass\n    attr_accessor :street, :city\n    protected :street=, :city=\n\n    def initialize(street:, city:)\n      @street = street\n      @city = city\n    end\n\n    def text\n      \"#{street}, #{city}\"\n    end\n\n    def ==(other)\n      self.class == other.class \u0026\u0026 \n        @street == other.instance_variable_get('@street') \u0026\u0026\n        @city == other.instance_variable_get('@city')\n    end\n\n    alias eql? ==\n\n    def hash\n      [ self.class, { street: @street, city: @city } ].hash\n    end\n\n    def freeze\n      @street.freeze\n      @city.freeze\n      super\n    end\n\n    def deconstruct\n      [ street, city ]\n    end\n\n    def deconstruct_keys(*)\n      { street: street, city: city }\n    end\n\n    def initialize_dup(source)\n      @street = source.instance_variable_get('@street').dup\n      @city = source.instance_variable_get('@city').dup\n      super\n    end\n\n    def initialize_clone(source)\n      @street = source.instance_variable_get('@street').clone\n      @city = source.instance_variable_get('@city').clone\n      super\n    end\n  end\nend\n```\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem 'portrayal'\n```\n\nAnd then execute:\n\n    $ bundle\n\nOr install it yourself as:\n\n    $ gem install portrayal\n\n## Usage\n\nThe 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:\n\n```ruby\nclass ApplicationStruct\n  include ActiveModel::Model\n  extend Portrayal\nend\n```\n\nNow you can inherit it when building domain objects.\n\n```ruby\nclass Address \u003c ApplicationStruct\n  keyword :street\n  keyword :city\n  keyword :postcode\n  keyword :country, default: nil\nend\n```\n\nPossible use cases for these objects include, but are not limited to:\n\n- Decorator/presenter objects\n- Tableless models\n- Objects serializable for 3rd party APIs\n- Objects serializable for React components\n\n### Defaults\n\nWhen specifying default, there's a difference between procs and lambda.\n\n```ruby\nkeyword :foo, default: proc { 2 + 2 } # =\u003e Will call this proc and return 4\nkeyword :foo, default: -\u003e { 2 + 2 }   # =\u003e Will return this lambda itself\n```\n\nAny other value works as normal.\n\n```ruby\nkeyword :foo, default: 4\n```\n\n#### Default procs\n\nDefault procs are executed as though they were called in your class's `initialize`, so they have access to other keywords and instance methods.\n\n```ruby\nkeyword :name\nkeyword :greeting, default: proc { \"Hello, #{name}\" }\n```\n\nDefaults can also use results of other defaults.\n\n```ruby\nkeyword :four,  default: proc { 2 + 2 }\nkeyword :eight, default: proc { four * 2 }\n```\n\nOr instance methods of the class.\n\n```ruby\nkeyword :id, default: proc { generate_id }\n\nprivate\n\ndef generate_id\n  SecureRandom.alphanumeric\nend\n```\n\nNote: The order in which you declare keywords matters when specifying defaults that depend on other keywords. This will not have the desired effect:\n\n```ruby\nkeyword :greeting, default: proc { \"Hello, #{name}\" }\nkeyword :name\n```\n\n### Nested Classes\n\nWhen you pass a block to a keyword, it creates a nested class named after camelized keyword name.\n\n```ruby\nclass Person\n  extend Portrayal\n\n  keyword :address do\n    keyword :street\n  end\nend\n```\n\nThe above block created class `Person::Address`.\n\nIf you want to change the name of the created class, use the option `define`.\n\n```ruby\nclass Person\n  extend Portrayal\n\n  keyword :visited_countries, define: 'Country' do\n    keyword :name\n  end\nend\n```\n\nThis defines `Person::Country`, while the accessor remains `visited_countries`.\n\n### Subclassing\n\nPortrayal supports subclassing.\n\n```ruby\nclass Person\n  extend Portrayal\n  \n  class \u003c\u003c self\n    def from_contact(contact)\n      new name:    contact.full_name,\n          address: contact.address.to_s,\n          email:   contact.email\n    end\n  end\n  \n  keyword :name\n  keyword :address\n  keyword :email, default: nil\nend\n```\n\n```ruby\nclass Employee \u003c Person\n  keyword :employee_id\n  keyword :email, default: proc { \"#{employee_id}@example.com\" }\nend\n```\n\nNow 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.\n\nHowever, 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.\n\n```ruby\n    def from_contact(contact, **kwargs)\n      new name:    contact.full_name,\n          address: contact.address.to_s,\n          email:   contact.email,\n          **kwargs\n    end\n```\n\nIf 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')`\n\n### Pattern Matching\n\nIf your Ruby has pattern matching, you can pattern match portrayal objects. Both array- and hash-style matching are supported.\n\n```ruby\nclass Point\n  extend Portrayal\n\n  keyword :x\n  keyword :y\nend\n\npoint = Point.new(x: 5, y: 10)\n\ncase point\nin 5, 10\n  'matched'\nelse\n  'did not match'\nend # =\u003e \"matched\"\n\ncase point\nin x:, y: 10\n  'matched'\nelse\n  'did not match'\nend # =\u003e \"matched\"\n```\n\n### Introspection\n\nEvery class that extends Portrayal receives a method called `portrayal`. This method is a schema of your object with some additional helpers.\n\n#### `portrayal.keywords`\n\nGet all keyword names.\n\n```ruby\nAddress.portrayal.keywords # =\u003e [:street, :city, :postcode, :country]\n```\n\n#### `portrayal.attributes(object)`\n\nGet all names + values as a hash.\n\n```ruby\naddress = Address.new(street: '34th st', city: 'NYC', postcode: '10001', country: 'USA')\nAddress.portrayal.attributes(address) # =\u003e {street: '34th st', city: 'NYC', postcode: '10001', country: 'USA'}\n```\n\n#### `portrayal.schema`\n\nGet everything portrayal knows about your keywords in one hash.\n\n```ruby\nAddress.portrayal.schema # =\u003e {:street=\u003enil, :city=\u003enil, :postcode=\u003enil, :country=\u003e\u003cPortrayal::Default @value=nil @callable=false\u003e}\n```\n\n## Philosophy\n\nPortrayal steps back from things like type enforcement, coercion, and writer methods in favor of read-only structs, and good old constructors.\n\n#### Good Constructors\n\nSince 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.\n\n```ruby\nclass Address \u003c ApplicationStruct\n  class \u003c\u003c self\n    def from_form(params)\n      raise ArgumentError, 'invalid postcode' if params[:postcode] !~ /\\A\\d+\\z/\n\n      new \\\n        street:   params[:street].to_s,\n        city:     params[:city].to_s,\n        postcode: params[:postcode].to_i,\n        country:  params[:country] || 'USA'\n    end\n\n    def from_some_service_api_object(object)\n      new \\\n        street:   \"#{object.houseNumber} #{object.streetName}\",\n        city:     object.city,\n        postcode: object.zipCode,\n        counry:   object.countryName != '' ? object.countryName : 'USA'\n    end\n  end\n\n  keyword :street\n  keyword :city\n  keyword :postcode\n  keyword :country, default: nil\nend\n```\n\nGood 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.\n\n```ruby\nclass Email \u003c ApplicationStruct\n  class \u003c\u003c self\n    # Extract parts of an email from JSON, and kick it over to from_parts.\n    def from_publishing_service_json(json)\n      subject, header, body, footer = *JSON.parse(json)\n      from_parts(subject: subject, header: header, body: body, footer: footer)\n    end\n\n    # Combine parts into the final keywords: subject and body.\n    def from_parts(subject:, header:, body:, footer:)\n      new(subject: subject, body: \"#{header}#{body}#{footer}\")\n    end\n  end\n\n  keyword :subject\n  keyword :body\nend\n```\n\nIf these contructors need more space to grow in complexity, they can be extracted into their own files.\n\n```\naddress/\n  from_form_constructor.rb\naddress.rb\n```\n\n```ruby\nclass Address \u003c ApplicationStruct\n  class \u003c\u003c self\n    def from_form(params)\n      self::FromFormConstructor.new(params).call\n    end\n  end\n\n  keyword :street\n  keyword :city\n  keyword :postcode\n  keyword :country, default: nil\nend\n```\n\nIf 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.\n\n#### No Reinventing The Wheel\n\nPortrayal 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`.\n\n```irb\n[1] pry(main)\u003e puts Address.portrayal.render_module_code\nattr_accessor :street, :city, :postcode, :country\nprotected :street=, :city=, :postcode=, :country=\ndef 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\ndef hash; [self.class, {street: @street, city: @city, postcode: @postcode, country: @country}].hash end\ndef ==(other); self.class == other.class \u0026\u0026 @street == other.instance_variable_get('@street') \u0026\u0026 @city == other.instance_variable_get('@city') \u0026\u0026 @postcode == other.instance_variable_get('@postcode') \u0026\u0026 @country == other.instance_variable_get('@country') end\nalias eql? ==\ndef freeze; @street.freeze; @city.freeze; @postcode.freeze; @country.freeze; super end\ndef 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\ndef 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\ndef deconstruct\n  public_syms = [:street, :city, :postcode, :country].select { |s| self.class.public_method_defined?(s) }\n  public_syms.map { |s| public_send(s) }\nend\ndef deconstruct_keys(keys)\n  filtered_keys = [:street, :city, :postcode, :country].select {|s| self.class.public_method_defined?(s) }\n  filtered_keys \u0026= keys if Array === keys\n  Hash[filtered_keys.map { |k| [k, public_send(k)] }]\nend\nalias initialize initialize\nalias hash hash\nalias == ==\nalias eql? eql?\nalias freeze freeze\nalias initialize_dup initialize_dup\nalias initialize_clone initialize_clone\nalias deconstruct deconstruct\nalias deconstruct_keys deconstruct_keys\nalias street street; alias street= street=; alias city city; alias city= city=; alias postcode postcode; alias postcode= postcode=; alias country country; alias country= country=\n```\n\n#### Implementation decisions\n\nHere 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](https://ruby.social/@maxim) / [twitter](https://twitter.com/hakunin).\n\n1. **Why do methods `#==`, `#eql?`, `#hash` rely on @instance @variables instead of calling reader methods?**  \n   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.\n2. **Why do methods `clone` and `dup` copy @instance @variables instead of calling reader methods?**  \n   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.\n3. **Why does pattern matching (`deconstruct`/`deconstruct_keys`) call reader methods rather than reading @instance @variables?**  \n   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.\n\n## Development\n\nAfter 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.\n\nTo 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](https://rubygems.org).\n\n## Contributing\n\nBug 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](http://contributor-covenant.org) code of conduct.\n\n## License\n\nThe gem is available as open source under the terms of the [Apache License Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt).\n\n## Code of Conduct\n\nEveryone interacting in the Portrayal project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/maxim/portrayal/blob/main/CODE_OF_CONDUCT.md).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmaxim%2Fportrayal","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmaxim%2Fportrayal","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmaxim%2Fportrayal/lists"}