ShallowAttributes
Simple and lightweight Virtus analog without any dependencies. Documentation.
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, fast_attributes
or attrio. 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 of ShallowAttributes compared to Virtus gems.
- Default ruby struct, dry-struct, virtus and ShallowAttributes ips and memory benchmarks
Installation
Add this line to your application's Gemfile:
gem 'shallow_attributes'
And then execute:
$ bundle
Or install it yourself as:
$ gem install shallow_attributes
Examples
Table of contents
- Using ShallowAttributes with Classes
- Default Values
- Mandatory Attributes
- Embedded Value
- Custom Coercions
- Collection Member Coercions
- Note about Member Coercions
- Overriding setters
- ActiveModel compatibility
- Dry-types
Using ShallowAttributes with Classes
You can create classes extended with Virtus and define attributes:
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:
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
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:
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
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
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
# 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:
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
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
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
<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
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:
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 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 code of conduct.
License
The gem is available as open source under the terms of the MIT License.