artisanal-model
This has been forked from goldstar/artisanal-model.
Artisanal::Model is a light-weight attribute modeling DSL that wraps dry-initializer, providing extra configuration and a slightly cleaner DSL.
The fork intends to patch some keyword argument caveats that were necessary for Stellar to upgrade to Ruby 3.0.6. The original functionality is meant to be kept intact.
Installation
Add this line to your application's Gemfile:
gem 'artisanal-model'
And then execute:
$ bundle
Or install it yourself as:
$ gem install artisanal-model
Configuration
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)
end
The 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
end
defaults(hash)
The 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 })
end
See the dry-initializer documentation for a list of most of the options that can be passed to the attribute
method.
writable(boolean, default: false)
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.
undefined(boolean, default: false)
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.
symbolize(boolean, default: false)
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".
Examples
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 }
)
end
You 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]'
end
Also, 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'
end
dry-initializer
For 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 required
aliased fields
The 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]'
coercions
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..count #=> 2
person..first.name #=> 'Ruby'
end
writers
Artisanal::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 ...
end
Notice 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'
end
serialization
Artisanal::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' }
end
undefined attributes
Dry-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 }
end
Benchmarks
Comparing 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
Development
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.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/goldstar/artisanal-model.