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.
Evolution...
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-validation 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
Installation
or in your Gemfile
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":"john@domain.com"}' # => {"email"=>"john@domain.com"} 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:
- virtus-localized: Localize the attributes
- virtus-relations: Add relations to Virtus objects
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
- Dan Kubb (dkubb)
- Chris Corbyn (d11wtq)
- Emmanuel Gomez (emmanuel)
- Fabio Rehm (fgrehm)
- Ryan Closner (rclosner)
- Markus Schirp (mbj)
- Yves Senn (senny)
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.
