virtus

by solnic

solnic /virtus

[DISCONTINUED ] Attributes on Steroids for Plain Old Ruby Objects

3.8K Stars 241 Forks Last release: Not found MIT License 1.6K Commits 28 Releases

Available items

No Items, yet!

The developer of this repository has not created any items for sale yet. Need a bug fixed? Help with integration? A different license? Create a request here:

DISCONTINUED

Working on virtus taught me a lot about handling data in Ruby, which involves coercions, type safety and validation (amongst other things). Even though the project has been successful, and serving well for many people, I decided to build something better. As a result, dry-types, dry-struct and dry-schema were born. These projects should be considered as virtus' successors, with better separation of concerns and better features. If you're interested in a modern take on same problems that virtus tried to solve, please check out these projects!

@solnic

Virtus

Gem Version Build Status Dependency Status Code Climate Test Coverage Inline docs

Virtus allows you to define attributes on classes, modules or class instances with optional information about types, reader/writer method visibility and coercion behavior. It supports a lot of coercions and advanced mapping of embedded objects and collections.

You can use it in many different contexts like:

  • Input parameter sanitization and coercion in web applications
  • Mapping JSON to domain objects
  • Encapsulating data-access in Value Objects
  • Domain model prototyping

And probably more.

Installation

$ gem install virtus

or in your Gemfile

gem 'virtus'

Examples

Using Virtus with Classes

You can create classes extended with Virtus and define attributes:

class User
  include Virtus.model

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

user = User.new(:name => 'Piotr', :age => 31) user.attributes # => { :name => "Piotr", :age => 31, :birthday => nil }

user.name # => "Piotr"

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

user.birthday = 'November 18th, 1983' # => #datetime:

mass-assignment

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

Cherry-picking extensions

# include attribute DSL + constructor + mass-assignment
class User
  include Virtus.model

attribute :name, String end

user = User.new(:name => 'Piotr') user.attributes = { :name => 'John' } user.attributes

=> {:name => 'John'}

include attribute DSL + constructor

class User include Virtus.model(:mass_assignment => false)

attribute :name, String end

User.new(:name => 'Piotr')

include just the attribute DSL

class User include Virtus.model(:constructor => false, :mass_assignment => false)

attribute :name, String end

user = User.new user.name = 'Piotr'

Using Virtus with Modules

You can create modules extended with Virtus and define attributes for later inclusion in your classes:

module Name
  include Virtus.module

attribute :name, String end

module Age include Virtus.module(:coerce => false)

attribute :age, Integer end

class User include Name, Age end

user = User.new(:name => 'John', :age => 30)

Dynamically Extending Instances

It's also possible to dynamically extend an object with Virtus:

class User
  # nothing here
end

user = User.new user.extend(Virtus.model) user.attribute :name, String user.name = 'John' user.name # => 'John'

Default Values

class Page
  include Virtus.model

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

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

Default values on dynamically extended instances

This requires you to set

:lazy
option because default values are set in the constructor if it's set to false (which is the default setting):
User = Class.new
user = User.new
user.extend(Virtus.model)
user.attribute :name, String, default: 'jane', lazy: true
user.name # => "jane"

Embedded Value

class City
  include Virtus.model

attribute :name, String end

class Address include Virtus.model

attribute :street, String attribute :zipcode, String attribute :city, City end

class User include Virtus.model

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"

Collection Member Coercions

# Support "primitive" classes
class Book
  include Virtus.model

attribute :page_numbers, Array[Integer] end

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

Support EmbeddedValues, too!

class Address include Virtus.model

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

class PhoneNumber include Virtus.model

attribute :number, String end

class User include Virtus.model

attribute :phone_numbers, Array[PhoneNumber] attribute :addresses, Set[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 # => [#<0x007fdb2d3bef88>, #<0x007fdb2d3beb00>]

user.addresses # => #<set: any st.>}> </0x007fdb2d3beb00></0x007fdb2d3bef88>

Hash attributes coercion

class Package
  include Virtus.model

attribute :dimensions, Hash[Symbol => Float] end

package = Package.new(:dimensions => { 'width' => "2.2", :height => 2, "length" => 4.5 }) package.dimensions # => { :width => 2.2, :height => 2.0, :length => 4.5 }

IMPORTANT note about Boolean type

Be aware that some libraries may do a terrible thing and define a global Boolean constant which breaks virtus' constant type lookup, if you see issues with the boolean type you can workaround it like that:

class User
  include Virtus.model

attribute :admin, Axiom::Types::Boolean end

This will be improved in Virtus 2.0.

IMPORTANT note about member coercions

Virtus 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 Virtus.model

attribute :title, String end

class Library include Virtus.model

attribute :books, Array[Book] end

library = Library.new

This will coerce Hash to a Book instance

library.books = [ { :title => 'Introduction to Virtus' } ]

This WILL NOT COERCE the value because you mutate the books array with Array#<<

library.books << { :title => 'Another Introduction to Virtus' }

A suggested solution to this problem would be to introduce your own class instead of using Array and implement mutation methods that perform coercions. For example:

class Book
  include Virtus.model

attribute :title, String end

class BookCollection < Array def < 'Another Introduction to Virtus' }

Value Objects

class GeoLocation
  include Virtus.value_object

values do attribute :latitude, Float attribute :longitude, Float end end

class Venue include Virtus.value_object

values do attribute :name, String attribute :location, GeoLocation end end

venue = Venue.new( :name => 'Pub', :location => { :latitude => 37.160317, :longitude => -98.437500 })

venue.location.latitude # => 37.160317 venue.location.longitude # => -98.4375

Supports object's equality

venue_other = Venue.new( :name => 'Other Pub', :location => { :latitude => 37.160317, :longitude => -98.437500 })

venue.location === venue_other.location # => true

Custom Coercions

require 'json'

class Json < Virtus::Attribute def coerce(value) value.is_a?(::Hash) ? value : JSON.parse(value) end end

class User include Virtus.model

attribute :info, Json, default: {} end

user = User.new user.info = '{"email":"[email protected]"}' # => {"email"=>"[email protected]"} user.info.class # => Hash

With a custom attribute encapsulating coercion-specific configuration

class NoisyString < Virtus::Attribute def coerce(value) value.to_s.upcase end end

class User include Virtus.model

attribute :scream, NoisyString end

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

Private Attributes

class User
  include Virtus.model

attribute :unique_id, String, :writer => :private

def set_unique_id(id) self.unique_id = id end end

user = User.new(:unique_id => '1234-1234') user.unique_id # => nil

user.unique_id = '1234-1234' # => NoMethodError: private method `unique_id='

user.set_unique_id('1234-1234') user.unique_id # => '1234-1234'

Overriding setters

class User
  include Virtus.model

attribute :name, String

def name=(new_name) custom_name = nil if new_name == "Godzilla" custom_name = "Can't tell" end super custom_name || new_name end end

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

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

Strict Coercion Mode

By default Virtus returns the input value even when it couldn't coerce it to the expected type. If you want to catch such cases in a noisy way you can use the strict mode in which Virtus raises an exception when it failed to coerce an input value.

class User
  include Virtus.model(:strict => true)

attribute :admin, Boolean end

this will raise an error

User.new :admin => "can't really say if true or false"

Nullify Blank Strings Mode

If you want to replace empty Strings with

nil
values (since they can't be coerced into the expected type), you can use the
:nullify_blank
option.
class User
  include Virtus.model(:nullify_blank => true)

attribute :birthday, Date end

User.new(:birthday => "").birthday # => nil

Building modules with custom configuration

You can also build Virtus modules that contain their own configuration.

YupNopeBooleans = Virtus.model { |mod|
  mod.coerce = true
  mod.coercer.config.string.boolean_map = { 'nope' => false, 'yup' => true }
}

class User include YupNopeBooleans

attribute :name, String attribute :admin, Boolean end

Or just include the module straight away ...

class User include Virtus.model(:coerce => false)

attribute :name, String attribute :admin, Boolean end

Attribute Finalization and Circular Dependencies

If a type references another type which happens to not be available yet you need to use lazy-finalization of attributes and finalize virtus manually after all types have been already loaded:

# in blog.rb
class Blog
  include Virtus.model(:finalize => false)

attribute :posts, Array['Post'] end

in post.rb

class Post include Virtus.model(:finalize => false)

attribute :blog, 'Blog' end

after loading both files just do:

Virtus.finalize

constants will be resolved:

Blog.attribute_set[:posts].member_type.primitive # => Post Post.attribute_set[:blog].type.primitive # => Blog

Plugins / Extensions

List of plugins/extensions that add features to Virtus:

Ruby version support

Virtus is known to work correctly with the following rubies:

  • 1.9.3
  • 2.0.0
  • 2.1.2
  • jruby
  • (probably) rbx

Credits

Contributing

  • Fork the project.
  • Make your feature addition or bug fix.
  • Add tests for it. This is important so I don't break it in a future version unintentionally.
  • Commit, do not mess with Rakefile or version (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
  • Send me a pull request. Bonus points for topic branches.

We use cookies. If you continue to browse the site, you agree to the use of cookies. For more information on our use of cookies please see our Privacy Policy.