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 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: 1983-11-18T00:00:00+00:00 (4891313/2,0/1,2299161)>

# 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 # => [#<PhoneNumber:0x007fdb2d3bef88 @number="212-555-1212">, #<PhoneNumber:0x007fdb2d3beb00 @number="919-444-3265">]

user.addresses # => #<Set: {#<Address:0x007fdb2d3be448 @address="1234 Any St.", @locality="Anytown", @region="DC", @postal_code="21234">}>

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 <<(book)
   if book.kind_of?(Hash)
    super(Book.new(book))
   else
     super
   end
  end
end

class Library
  include Virtus.model

  attribute :books, BookCollection[Book]
end

library = Library.new
library.books << { :title => '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