Artisanal::Model is a light-weight attribute modeling DSL that wraps dry-initializer, providing extra configuration and a slightly cleaner DSL.
Add this line to your application's Gemfile:
gem 'artisanal-model'And then execute:
$ bundle
Or install it yourself as:
$ gem install artisanal-model
Artisanal::Model configuration is done on a per-model basis. There is no global configuration (at this time):
class Model
include Artisanal::Model(writable: true)
endThe configuration will be carried down to subclasses automatically. However, and subclass can override any settings with the configure method:
class Person < Model
artisanal_model.configure { |config| config.writable = false }
# ... or
artisanal_model.config.writable = false
endThe defaults setting allows you to provide default values to the attribute dsl method. For example, if you would like all attributes to be optional and private:
class Model
include Artisanal::Model(defaults: { optional: true, reader: :private })
endSee the dry-initializer documentation for a list of most of the options that can be passed to the attribute method.
Setting writable to true will enable the mass-assignment #assign_attributes method as well as add writer: true to the defaults configuration option.
You can also manually add writer: true to defaults without setting writable to true. This would give you attribute writers but skip creating the mass-assignment method.
Setting undefined to true will configure dry-initializer to differentiate between nil values and undefineds. It will also automatically filter out undefined values when serializing your model to a hash.
See dry-initializer Skip Undefined documentation for more information.
Setting symbolize to true will make artisanal-model intern the keys of any attributes passed in during initialization and mass-assignment. Only attributes belonging to the model will be symbolized; all
other keys will be left as strings.
See the integration test for more details. See the benchmarks for the performance impact of "indifferent access".
For the following examples, consider the following Model class with some default configuration.
class Model
include Artisanal::Model(
defaults: { optional: true, type: Dry::Types::Any }
)
endYou can define attributes on your models using Artisanal::Model's attribute dsl method:
class Person < Model
attribute :first_name
attribute :last_name
attribute :email
end
Person.new(first_name: 'John', last_name: 'Smith', email: '[email protected]').tap do |person|
person.first_name #=> 'John'
person.email #=> '[email protected]'
endAlso, the keys passed into the initializer do not need to be symbolized ahead of time. Artisanal::Model will take care of that for you before passing them into dry-initializer:
Person.new('first_name' => 'John').tap do |person|
person.first_name #=> 'John'
endFor the most part, the parameters available to the attribute method are the same ones available to dry-initializer's option method. These allow you to do things like coerce values, rename incoming fields, set defaults, define required fields, and set method access control.
class Person < Model
attribute :first_name, default: -> { 'Bob' }
attribute :last_name, optional: false
attribute :email, from: :email_address
attribute :phone, reader: :private
attribute :age, ->(value, person) { value.to_i }
end
attrs = {
last_name: 'Smith',
email_address: '[email protected]',
phone: '555.123.4567',
age: '37'
}
Person.new(attrs).tap do |person|
person.first_name #=> 'Bob'
person.email #=> '[email protected]'
person.phone #=> NoMethodError: private method `phone' called for...
person.age #=> 37
end
Person.new(first_name: 'Steve')
#=> KeyError: Person: option 'last_name' is requiredThe dry-initializer gem already lets you use the :as option to give your field a new name. To make this a little more straightforward, artisanal-model adds a :from option that is the inverse of :as:
class Person < Model
attribute :email_address, as: :email
# is the same as ...
attribute :email, from: :email_address
end
Person.new(email_address: '[email protected]').email #=> '[email protected]'In addition to the functionality dry-initializer provides, Artisanal::Model also adds some niceties that make the dsl a little less verbose. For example, coercions in dry-initializer are required to be a callable type (e.g. a proc or a dry-type).
However, Artisanal::Model will allow you to specify a class or an array and will wrap the type coercion with a proc in the background:
class Address < Model
attribute :street
attribute :city
attribute :state
attribute :zip
end
class Tag < Model
attribute :name
end
class Person < Model
attribute :name
attribute :address, Address
attribute :tags, Array[Tag]
attribute :emails, Set[Dry::Types['string']]
end
attrs = {
name: 'John Smith',
address: {
street: '123 Main St.',
city: 'Portland',
state: 'OR',
zip: '97213'
},
tags: [
{ name: 'Ruby' },
{ name: 'Developer' }
],
email: ['[email protected]', '[email protected]']
}
Person.new(attrs).tap do |person|
person.name #=> 'John Smith'
person.address.street #=> '123 Main St.'
person.address.zip #=> '97213'
person.tags.count #=> 2
person.tags.first.name #=> 'Ruby'
endArtisanal::Model can also add writer methods that aren't provided from dry-initializer:
# Model.include Artisanal::Model(writable: true, ...)
class Person < Model
attribute :name
attribute :email, writer: false
attribute :phone, writer: :protected # the same as adding `protected :phone`
attribute :age, writer: :private # the same as adding `private :age`
end
attrs = {
name: 'John',
email: '[email protected]',
phone: '555.123.4567',
age: '37'
}
Person.new(attrs).tap do |person|
person.name = 'Bob'
person.name #=> 'Bob'
person.email = '[email protected]' # => NoMethodError: undefined method `email' called for ...
person.phone = '555.987.6543' # => NoMethodError: protected method `phone' called for ...
person.age = '21' # => NoMethodError: private method `age' called for ...
endNotice that any other value except for false, :protected and :private provides a public writer.
With writable enabled, models will also have a assign_attributes method to do attribute mass-assignment:
class Person < Model
attribute :name
attribute :email
attribute :age
end
Person.new(name: 'John Smith', email: '[email protected]', age: '37').tap do |person|
person.name #=> 'John Smith'
person.assign_attributes(name: 'Bob Johnson', email: '[email protected]')
person.name #=> 'Bob Johnson'
person.email #=> '[email protected]'
person.age #=> '37'
endArtisanal::Models can also be converted back into hashes for storage or representation purposes. By default, the result will only include public attributes, but to_h will also let you request private attributes as well:
class Person < Model
attribute :name
attribute :email
attribute :phone, reader: :private
attribute :age, reader: :protected
end
Person.new(name: 'John Smith', phone: '555.123.4567', age: '37').tap do |person|
person.to_h #=> { name: 'John Smith', email: nil }
person.to_h(scope: :private) #=> { phone: '555.123.4567' }
person.to_h(scope: [:public, :protected]) #=> { name: 'John Smith', email: nil, age: '37' }
person.to_h(scope: :all) #=> { name: 'John Smith', email: nil, phone: '555.123.4567', age: '37' }
endDry-initializer differentiates between a nil value passed in for an attribute and nothing passed in at all.
This can be turned off through Artisanal::Model for performance reasons if you don't care about the differences between nil and undefined. However, if turned on, serializing to a hash will also exclude undefined values by default:
# Model.include Artisanal::Model(undefined: true, ...)
class Person < Model
attribute :name
attribute :email
attribute :phone
end
Person.new(name: 'John Smith', phone: nil).tap do |person|
person.to_h #=> { name: 'John Smith', phone: nil }
person.to_h(include_undefined: true) #=> { name: 'John Smith', email: nil, phone: nil }
endComparing artisanal-model with plain ruby, dry-initializer, hashie, and virtus:
Calculating -------------------------------------
plain Ruby 2.493M (± 2.8%) i/s - 37.407M in 15.016557s
dry-initializer 402.247k (± 2.7%) i/s - 6.051M in 15.054567s
artisanal-model 322.343k (± 3.2%) i/s - 4.843M in 15.040670s
artisanal-model (WITH WRITERS)
329.785k (± 2.6%) i/s - 4.965M in 15.066329s
artisanal-model (WITH INDIFFERENT ACCESS)
284.767k (± 2.2%) i/s - 4.292M in 15.078616s
hashie 37.250k (± 1.8%) i/s - 559.827k in 15.034072s
virtus 136.092k (± 2.0%) i/s - 2.049M in 15.059855s
Comparison:
plain Ruby: 2492919.5 i/s
dry-initializer: 402247.4 i/s - 6.20x slower
artisanal-model (WITH WRITERS): 329785.0 i/s - 7.56x slower
artisanal-model: 322342.7 i/s - 7.73x slower
artisanal-model (WITH INDIFFERENT ACCESS): 284766.8 i/s - 8.75x slower
virtus: 136092.4 i/s - 18.32x slower
hashie: 37250.4 i/s - 66.92x slower
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/goldstar/artisanal-model.