davydovanton/shallow_attributes

View on GitHub
README.md

Summary

Maintainability
Test Coverage
# ShallowAttributes

[![Build Status](https://travis-ci.org/davydovanton/shallow_attributes.svg?branch=master)](https://travis-ci.org/davydovanton/shallow_attributes)
[![Code Climate](https://codeclimate.com/github/davydovanton/shallow_attributes/badges/gpa.svg)](https://codeclimate.com/github/davydovanton/shallow_attributes)
[![Coverage Status](https://coveralls.io/repos/github/davydovanton/shallow_attributes/badge.svg?branch=master)](https://coveralls.io/github/davydovanton/shallow_attributes?branch=master)
[![Inline docs](http://inch-ci.org/github/davydovanton/shallow_attributes.svg?branch=master)](http://inch-ci.org/github/davydovanton/shallow_attributes)

Simple and lightweight Virtus analog without any dependencies. [Documentation][doc-link].

## Motivation

There are already a lot of good and flexible gems which solve a similar problem, allowing attributes
to be defined with their types, for example: [virtus][virtus-link], [fast_attributes][fast-attributes-link]
or [attrio][attrio-link]. However, the disadvantage of these gems is performance or API. So, the goal
of `ShallowAttributes` is to provide a simple solution which is similar to the `Virtus` API, simple, fast,
understandable and extendable.

* This is [the performance benchmark][performance-benchmark] of ShallowAttributes compared to Virtus gems.
* [Default ruby struct, dry-struct, virtus and ShallowAttributes ips and memory benchmarks](https://gist.github.com/IvanShamatov/94e78ca52f04f20c6085651345dbdfda)

## Installation

Add this line to your application's Gemfile:

``` ruby
gem 'shallow_attributes'
```

And then execute:

    $ bundle

Or install it yourself as:

    $ gem install shallow_attributes

## Examples

### Table of contents

* [Using ShallowAttributes with Classes](#using-shallowattributes-with-classes)
* [Default Values](#default-values)
* [Mandatory Attributes](#mandatory-attributes)
* [Embedded Value](#embedded-value)
* [Custom Coercions](#custom-coercions)
* [Collection Member Coercions](#collection-member-coercions)
* [Note about Member Coercions](#important-note-about-member-coercions)
* [Overriding setters](#overriding-setters)
* [ActiveModel compatibility](#activemodel-compatibility)
* [Dry-types](#dry-types)

### Using ShallowAttributes with Classes

You can create classes extended with Virtus and define attributes:

``` ruby
class User
  include ShallowAttributes

  attribute :name, String
  attribute :age, Integer
  attribute :birthday, DateTime
end

class SuperUser < User
  include ShallowAttributes

  attribute :name, String
  attribute :age, Integer, allow_nil: true
  attribute :birthday, DateTime
end

user = User.new(name: 'Anton', age: 31)
user.name       # => "Anton"

user.age = '31' # => 31
user.age = nil  # => nil
user.age.class  # => Fixnum

user.birthday = 'November 18th, 1983' # => #<DateTime: 1983-11-18T00:00:00+00:00 (4891313/2,0/1,2299161)>

user.attributes # => { name: "Anton", age: 31, birthday: nil }

# mass-assignment
user.attributes = { name: 'Jane', age: 21 }
user.name       # => "Jane"
user.age        # => 21

super_user = SuperUser.new
user.age = nil  # => 0
```

ShallowAttributes doesn't make any assumptions about base classes. There is no need to define
default attributes, or even mix ShallowAttributes into the base class:

``` ruby
require 'active_model'

class Form
  extend ActiveModel::Naming
  extend ActiveModel::Translation
  include ActiveModel::Conversion
  include ShallowAttributes

  def persisted?
    false
  end
end

class SearchForm < Form
  attribute :name, String
end

form = SearchForm.new(name: 'Anton')
form.name # => "Anton"
```

### Default Values

``` ruby
class Page
  include ShallowAttributes

  attribute :title, String

  # default from a singleton value (integer in this case)
  attribute :views, Integer, default: 0

  # default from a singleton value (boolean in this case)
  attribute :published, 'Boolean', default: false

  # default from a callable object (proc in this case)
  attribute :slug, String, default: lambda { |page, attribute| page.title.downcase.gsub(' ', '-') }

  # default from a method name as symbol
  attribute :editor_title, String,  default: :default_editor_title

  private

  def default_editor_title
    published ? title : "UNPUBLISHED: #{title}"
  end
end

page = Page.new(title: 'Virtus README')
page.slug         # => 'virtus-readme'
page.views        # => 0
page.published    # => false
page.editor_title # => "UNPUBLISHED: Virtus README"

page.views = 10
page.views                    # => 10
page.reset_attribute(:views)  # => 0
page.views                    # => 0
```

### Mandatory attributes
You can provide `present: true` option for any attribute that will prevent class from initialization
if this attribute was not provided:

``` ruby
class CreditCard
  include ShallowAttributes
  attribute :number, Integer, present: true
  attribute :owner, String, present: true
end

card = CreditCard.new(number: 1239342)
# => ShallowAttributes::MissingAttributeError: Mandatory attribute "owner" was not provided
```


### Embedded Value

``` ruby
class City
  include ShallowAttributes

  attribute :name, String
  attribute :size, Integer, default: 9000
end

class Address
  include ShallowAttributes

  attribute :street,  String
  attribute :zipcode, String, default: '111111'
  attribute :city,    City
end

class User
  include ShallowAttributes

  attribute :name,    String
  attribute :address, Address
end

user = User.new(address: {
  street: 'Street 1/2',
  zipcode: '12345',
  city: {
    name: 'NYC'
  }
})

user.address.street    # => "Street 1/2"
user.address.city.name # => "NYC"
```

### Custom Coercions

``` ruby
require 'json'

class Json
  def coerce(value, options = {})
    value.is_a?(::Hash) ? value : JSON.parse(value)
  end
end

class User
  include ShallowAttributes

  attribute :info, Json, default: {}
end

user = User.new
user.info = '{"email":"john@domain.com"}' # => {"email"=>"john@domain.com"}
user.info.class                           # => Hash

# With a custom attribute encapsulating coercion-specific configuration
class NoisyString
  def coerce(value, options = {})
    value.to_s.upcase
  end
end

class User
  include ShallowAttributes

  attribute :scream, NoisyString
end

user = User.new(scream: 'hello world!')
user.scream # => "HELLO WORLD!"
```

### Collection Member Coercions

``` ruby
# Support "primitive" classes
class Book
  include ShallowAttributes

  attribute :page_numbers, Array, of: Integer
end

book = Book.new(:page_numbers => %w[1 2 3])
book.page_numbers # => [1, 2, 3]

# Support EmbeddedValues, too!
class Address
  include ShallowAttributes

  attribute :address,     String
  attribute :locality,    String
  attribute :region,      String
  attribute :postal_code, String
end

class PhoneNumber
  include ShallowAttributes

  attribute :number, String
end

class User
  include ShallowAttributes

  attribute :phone_numbers, Array, of: PhoneNumber
  attribute :addresses,     Array, of: Address
end

user = User.new(
  :phone_numbers => [
    { :number => '212-555-1212' },
    { :number => '919-444-3265' } ],
  :addresses => [
    { :address => '1234 Any St.', :locality => 'Anytown', :region => "DC", :postal_code => "21234" } ])

user.phone_numbers # => [#<PhoneNumber:0x007fdb2d3bef88 @number="212-555-1212">, #<PhoneNumber:0x007fdb2d3beb00 @number="919-444-3265">]
user.addresses     # => [#<Address:0x007fdb2d3be448 @address="1234 Any St.", @locality="Anytown", @region="DC", @postal_code="21234">]

user.attributes
# => {
# =>   :phone_numbers => [
# =>     { :number => '212-555-1212' },
# =>     { :number => '919-444-3265' }
# =>   ],
# =>   :addresses => [
# =>     {
# =>       :address => '1234 Any St.',
# =>       :locality => 'Anytown',
# =>       :region => "DC",
# =>       :postal_code => "21234"
# =>     }
# =>   ]
# => }
```

### IMPORTANT note about member coercions

ShallowAttributes performs coercions only when a value is being assigned. If you mutate the value
later on using its own interfaces then coercion won't be triggered.

Here's an example:

``` ruby
class Book
  include ShallowAttributes
  attribute :title, String
end

class Library
  include ShallowAttributes
  attribute :books, Array, of: Book
end

library = Library.new

# This will coerce Hash to a Book instance
library.books = [ { :title => 'Introduction' } ]

# This WILL NOT COERCE the value because you mutate the books array with Array#<<
library.books << { :title => 'Another Introduction' }
```

### Overriding setters

``` ruby
class User
  include ShallowAttributes

  attribute :name, String

  alias_method :_name=, :name=
  def name=(new_name)
    custom_name = nil
    if new_name == "Godzilla"
      custom_name = "Can't tell"
    end

    self._name = custom_name || new_name
  end
end

user = User.new(name: "Frank")
user.name # => 'Frank'

user = User.new(name: "Godzilla")
user.name # => 'Can't tell'
```

### ActiveModel compatibility

ShallowAttributes is fully compatible with ActiveModel.

#### Form object

``` ruby
require 'active_model'

class SearchForm
  extend ActiveModel::Naming
  extend ActiveModel::Translation
  include ActiveModel::Conversion
  include ShallowAttributes

  attribute :name, String
  attribute :service_ids, Array, of: Integer
  attribute :archived, 'Boolean', default: false

  def persisted?
    false
  end

  def results
    # ...
  end
end

class SearchesController < ApplicationController
  def index
    search_params = params.require(:search_form).permit(...)
    @search_form = SearchForm.new(search_params)
  end
end
```

``` erb
<h1>Search</h1>
<%= form_for @search_form do |f| %>
  <%= f.text_field :name %>
  <%= f.collection_check_boxes :service_ids, Service.all, :id, :name %>
  <%= f.select :archived, [['Archived', true], ['Not Archived', false]] %>
<% end %>
```

#### Validations

``` ruby
require 'active_model'

class Children
  include ShallowAttributes
  include ActiveModel::Validations

  attribute :scream, String
  validates :scream, presence: true
end

user = User.new(scream: '')
user.valid? # => false
user.scream = 'hello world!'
user.valid? # => true
```

### Dry-types
You can use dry-types objects as a type for your attribute:
```ruby
module Types
  include Dry::Types.module
end

class User
  include ShallowAttributes

  attribute :name, Types::Coercible::String
  attribute :age, Types::Coercible::Int
  attribute :birthday, DateTime
end

user = User.new(name: nil, age: 0)
user.name # => ''
user.age # => 0
```

## Ruby version support

ShallowAttributes is [known to work correctly][travis-link] with the following rubies:

* 2.0
* 2.1
* 2.2
* 2.3
* 2.4
* jruby-head

Also we run rbx-2 buld too.

## Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/davydovanton/shallow_attributes.
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.

## License

The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).

[doc-link]: http://www.rubydoc.info/github/davydovanton/shallow_attributes/master
[virtus-link]: https://github.com/solnic/virtus
[fast-attributes-link]: https://github.com/applift/fast_attributes
[attrio-link]: https://github.com/jetrockets/attrio
[performance-benchmark]: https://gist.github.com/davydovanton/d14b51ab63e3fab63ecb
[travis-link]: https://travis-ci.org/davydovanton/shallow_attributes