Skip to content

Repository files navigation

Virtus

Gem VersionBuild StatusDependency StatusCode ClimateTest CoverageInline 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.

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

$ gem install virtus

or in your Gemfile

gem'virtus'

Examples

Using Virtus with Classes

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

Cherry-picking extensions

# 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'

Using Virtus with Modules

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)

Dynamically Extending Instances

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'

Default Values

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# => 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.newuser=User.newuser.extend(Virtus.model)user.attribute:name,String,default: 'jane',lazy: trueuser.name# => "jane"

Embedded Value

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"

Collection Member Coercions

# 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">}>

Hash attributes coercion

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 }

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:

classUserincludeVirtus.modelattribute:admin,Axiom::Types::Booleanend

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:

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'}

Value Objects

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# => true

Custom Coercions

require'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!"

Private Attributes

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'

Overriding setters

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'

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.

classUserincludeVirtus.model(:strict=>true)attribute:admin,Booleanend# this will raise an errorUser.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.

classUserincludeVirtus.model(:nullify_blank=>true)attribute:birthday,DateendUser.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=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,Booleanend

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.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# => 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.

About

Attributes on Steroids for Plain Old Ruby Objects

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages