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.
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
$ gem install virtus
or in your Gemfile
gem'virtus'You can create classes extended with Virtus and define attributes:
classUserincludeVirtus.modelattribute:name,Stringattribute:age,Integerattribute:birthday,DateTimeenduser=User.new(:name=>'Piotr',:age=>31)user.attributes# => { :name => "Piotr", :age => 31, :birthday => nil }user.name# => "Piotr"user.age='31'# => 31user.age.class# => Fixnumuser.birthday='November 18th, 1983'# => #<DateTime: 1983-11-18T00:00:00+00:00 (4891313/2,0/1,2299161)># mass-assignmentuser.attributes={:name=>'Jane',:age=>21}user.name# => "Jane"user.age# => 21# include attribute DSL + constructor + mass-assignmentclassUserincludeVirtus.modelattribute:name,Stringenduser=User.new(:name=>'Piotr')user.attributes={:name=>'John'}user.attributes# => {:name => 'John'}# include attribute DSL + constructorclassUserincludeVirtus.model(:mass_assignment=>false)attribute:name,StringendUser.new(:name=>'Piotr')# include just the attribute DSLclassUserincludeVirtus.model(:constructor=>false,:mass_assignment=>false)attribute:name,Stringenduser=User.newuser.name='Piotr'You can create modules extended with Virtus and define attributes for later inclusion in your classes:
moduleNameincludeVirtus.moduleattribute:name,StringendmoduleAgeincludeVirtus.module(:coerce=>false)attribute:age,IntegerendclassUserincludeName,Ageenduser=User.new(:name=>'John',:age=>30)It's also possible to dynamically extend an object with Virtus:
classUser# nothing hereenduser=User.newuser.extend(Virtus.model)user.attribute:name,Stringuser.name='John'user.name# => 'John'classPageincludeVirtus.modelattribute: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 symbolattribute:editor_title,String,:default=>:default_editor_titledefdefault_editor_titlepublished? ? title : "UNPUBLISHED: #{title}"endendpage=Page.new(:title=>'Virtus README')page.slug# => 'virtus-readme'page.views# => 0page.published# => falsepage.editor_title# => "UNPUBLISHED: Virtus README"page.views=10page.views# => 10page.reset_attribute(:views)# => 0page.views# => 0This 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.newuser=User.newuser.extend(Virtus.model)user.attribute:name,String,default: 'jane',lazy: trueuser.name# => "jane"classCityincludeVirtus.modelattribute:name,StringendclassAddressincludeVirtus.modelattribute:street,Stringattribute:zipcode,Stringattribute:city,CityendclassUserincludeVirtus.modelattribute:name,Stringattribute:address,Addressenduser=User.new(:address=>{:street=>'Street 1/2',:zipcode=>'12345',:city=>{:name=>'NYC'}})user.address.street# => "Street 1/2"user.address.city.name# => "NYC"# Support "primitive" classesclassBookincludeVirtus.modelattribute:page_numbers,Array[Integer]endbook=Book.new(:page_numbers=>%w[123])book.page_numbers# => [1, 2, 3]# Support EmbeddedValues, too!classAddressincludeVirtus.modelattribute:address,Stringattribute:locality,Stringattribute:region,Stringattribute:postal_code,StringendclassPhoneNumberincludeVirtus.modelattribute:number,StringendclassUserincludeVirtus.modelattribute:phone_numbers,Array[PhoneNumber]attribute:addresses,Set[Address]enduser=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">}>classPackageincludeVirtus.modelattribute:dimensions,Hash[Symbol=>Float]endpackage=Package.new(:dimensions=>{'width'=>"2.2",:height=>2,"length"=>4.5})package.dimensions# => { :width => 2.2, :height => 2.0, :length => 4.5 }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:
classUserincludeVirtus.modelattribute:admin,Axiom::Types::BooleanendThis will be improved in Virtus 2.0.
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:
classBookincludeVirtus.modelattribute:title,StringendclassLibraryincludeVirtus.modelattribute:books,Array[Book]endlibrary=Library.new# This will coerce Hash to a Book instancelibrary.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:
classBookincludeVirtus.modelattribute:title,StringendclassBookCollection < Arraydef <<(book)ifbook.kind_of?(Hash)super(Book.new(book))elsesuperendendendclassLibraryincludeVirtus.modelattribute:books,BookCollection[Book]endlibrary=Library.newlibrary.books << {:title=>'Another Introduction to Virtus'}classGeoLocationincludeVirtus.value_objectvaluesdoattribute:latitude,Floatattribute:longitude,FloatendendclassVenueincludeVirtus.value_objectvaluesdoattribute:name,Stringattribute:location,GeoLocationendendvenue=Venue.new(:name=>'Pub',:location=>{:latitude=>37.160317,:longitude=> -98.437500})venue.location.latitude# => 37.160317venue.location.longitude# => -98.4375# Supports object's equalityvenue_other=Venue.new(:name=>'Other Pub',:location=>{:latitude=>37.160317,:longitude=> -98.437500})venue.location === venue_other.location# => truerequire'json'classJson < Virtus::Attributedefcoerce(value)value.is_a?(::Hash) ? value : JSON.parse(value)endendclassUserincludeVirtus.modelattribute:info,Json,default: {}enduser=User.newuser.info='{"email":"john@domain.com"}'# => {"email"=>"john@domain.com"}user.info.class# => Hash# With a custom attribute encapsulating coercion-specific configurationclassNoisyString < Virtus::Attributedefcoerce(value)value.to_s.upcaseendendclassUserincludeVirtus.modelattribute:scream,NoisyStringenduser=User.new(:scream=>'hello world!')user.scream# => "HELLO WORLD!"classUserincludeVirtus.modelattribute:unique_id,String,:writer=>:privatedefset_unique_id(id)self.unique_id=idendenduser=User.new(:unique_id=>'1234-1234')user.unique_id# => niluser.unique_id='1234-1234'# => NoMethodError: private method `unique_id='user.set_unique_id('1234-1234')user.unique_id# => '1234-1234'classUserincludeVirtus.modelattribute:name,Stringdefname=(new_name)custom_name=nilifnew_name == "Godzilla"custom_name="Can't tell"endsupercustom_name || new_nameendenduser=User.new(name: "Frank")user.name# => 'Frank'user=User.new(name: "Godzilla")user.name# => 'Can't tell'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.
classUserincludeVirtus.model(:strict=>true)attribute:admin,Booleanend# this will raise an errorUser.new:admin=>"can't really say if true or false"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.
classUserincludeVirtus.model(:nullify_blank=>true)attribute:birthday,DateendUser.new(:birthday=>"").birthday# => nilYou can also build Virtus modules that contain their own configuration.
YupNopeBooleans=Virtus.model{ |mod|
mod.coerce=truemod.coercer.config.string.boolean_map={'nope'=>false,'yup'=>true}}classUserincludeYupNopeBooleansattribute:name,Stringattribute:admin,Booleanend# Or just include the module straight away ...classUserincludeVirtus.model(:coerce=>false)attribute:name,Stringattribute:admin,BooleanendIf 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.rbclassBlogincludeVirtus.model(:finalize=>false)attribute:posts,Array['Post']end# in post.rbclassPostincludeVirtus.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# => PostPost.attribute_set[:blog].type.primitive# => BlogList of plugins/extensions that add features to Virtus:
- virtus-localized: Localize the attributes
- virtus-relations: Add relations to Virtus objects
Virtus is known to work correctly with the following rubies:
- 1.9.3
- 2.0.0
- 2.1.2
- jruby
- (probably) rbx
- Dan Kubb (dkubb)
- Chris Corbyn (d11wtq)
- Emmanuel Gomez (emmanuel)
- Fabio Rehm (fgrehm)
- Ryan Closner (rclosner)
- Markus Schirp (mbj)
- Yves Senn (senny)
- 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.
