Create "immutable" objects with no setters, just getters.
This gem allows you to define "immutable" objects, when using it your objects will only have getters and no setters.
So, if you change [1] [2] an attribute of the object, you’ll have a new object instance. That is, you transform the object instead of modifying it.
Documentation
Table of contents
Installation
Add this line to your application's Gemfile and bundle install
:
gem 'u-attributes'
Compatibility
u-attributes | branch | ruby | activemodel |
---|
unreleased | main | >= 2.2.0 | >= 3.2, < 7 |
2.8.0 | v2.x | >= 2.2.0 | >= 3.2, < 7 |
1.2.0 | v1.x | >= 2.2.0 | >= 3.2, < 6.1 |
Note: The activemodel is an optional dependency, this module can be enabled to validate the attributes.
⬆️ Back to Top
Usage
How to define attributes?
By default, you must define the class constructor.
class Person
include Micro::Attributes
attribute :age
attribute :name
def initialize(name: 'John Doe', age:)
@name, @age = name, age
end
end
person = Person.new(age: 21)
person.age
person.name
⬆️ Back to Top
Micro::Attributes#attributes=
This is a protected method to make easier the assignment in a constructor. e.g.
class Person
include Micro::Attributes
attribute :age
attribute :name, default: 'John Doe'
def initialize(options)
self.attributes = options
end
end
person = Person.new(age: 20)
person.age
person.name
You can extract attributes using the extract_attributes_from
method, it will try to fetch attributes from the
object using either the object[attribute_key]
accessor or the reader method object.attribute_key
.
class Person
include Micro::Attributes
attribute :age
attribute :name, default: 'John Doe'
def initialize(user:)
self.attributes = extract_attributes_from(user)
end
end
class User
attr_accessor :age, :name
end
user = User.new
user.age = 20
person = Person.new(user: user)
person.age
person.name
another_person = Person.new(user: { age: 55, name: 'Julia Not Roberts' })
another_person.age
another_person.name
Is it possible to define an attribute as required?
You only need to use the required: true
option.
But to this work, you need to assign the attributes using the #attributes=
method or the extensions: initialize, activemodel_validations.
class Person
include Micro::Attributes
attribute :age
attribute :name, required: true
def initialize(attributes)
self.attributes = attributes
end
end
Person.new(age: 32)
⬆️ Back to Top
Micro::Attributes#attribute
Use this method with a valid attribute name to get its value.
person = Person.new(age: 20)
person.attribute('age')
person.attribute(:name)
person.attribute('foo')
If you pass a block, it will be executed only if the attribute was valid.
person.attribute(:name) { |value| puts value }
person.attribute('age') { |value| puts value }
person.attribute('foo') { |value| puts value }
⬆️ Back to Top
Micro::Attributes#attribute!
Works like the #attribute
method, but it will raise an exception when the attribute doesn't exist.
person.attribute!('foo')
person.attribute!('foo') { |value| value }
⬆️ Back to Top
How to define multiple attributes?
Use .attributes
with a list of attribute names.
class Person
include Micro::Attributes
attributes :age, :name
def initialize(options)
self.attributes = options
end
end
person = Person.new(age: 32)
person.name
person.age
Note: This method can't define default values. To do this, use the #attribute()
method.
⬆️ Back to Top
Micro::Attributes.with(:initialize)
Use Micro::Attributes.with(:initialize)
to define a constructor to assign the attributes. e.g.
class Person
include Micro::Attributes.with(:initialize)
attribute :age, required: true
attribute :name, default: 'John Doe'
end
person = Person.new(age: 18)
person.age
person.name
This extension enables two methods for your objects.
The #with_attribute()
and #with_attributes()
.
#with_attribute()
another_person = person.with_attribute(:age, 21)
another_person.age
another_person.name
another_person.equal?(person)
#with_attributes()
Use it to assign multiple attributes
other_person = person.with_attributes(name: 'Serradura', age: 32)
other_person.age
other_person.name
other_person.equal?(person)
If you pass a value different of a Hash, a Kind::Error will be raised.
Person.new(1)
⬆️ Back to Top
Defining default values to the attributes
To do this, you only need make use of the default:
keyword. e.g.
class Person
include Micro::Attributes.with(:initialize)
attribute :age
attribute :name, default: 'John Doe'
end
There are two different strategies to define default values.
- Pass a regular object, like in the previous example.
- Pass a
proc
/lambda
, and if it has an argument you will receive the attribute value to do something before assign it.
class Person
include Micro::Attributes.with(:initialize)
attribute :age, default: -> age { age&.to_i }
attribute :name, default: -> name { String(name || 'John Doe').strip }
end
⬆️ Back to Top
The strict initializer
Use .with(initialize: :strict)
to forbids an instantiation without all the attribute keywords.
In other words, it is equivalent to you define all the attributes using the required: true
option.
class StrictPerson
include Micro::Attributes.with(initialize: :strict)
attribute :age
attribute :name, default: 'John Doe'
end
StrictPerson.new({})
An attribute with a default value can be omitted.
person_without_age = StrictPerson.new(age: nil)
person_without_age.age
person_without_age.name
Note: Except for this validation the .with(initialize: :strict)
method will works in the same ways of .with(:initialize)
.
⬆️ Back to Top
Is it possible to inherit the attributes?
Yes. e.g.
class Person
include Micro::Attributes.with(:initialize)
attribute :age
attribute :name, default: 'John Doe'
end
class Subclass < Person
attribute :foo
end
instance = Subclass.new({})
instance.name
instance.respond_to?(:age)
instance.respond_to?(:foo)
⬆️ Back to Top
.attribute!()
This method allows us to redefine the attributes default data that was defined in the parent class. e.g.
class AnotherSubclass < Person
attribute! :name, default: 'Alfa'
end
alfa_person = AnotherSubclass.new({})
alfa_person.name
alfa_person.age
class SubSubclass < Subclass
attribute! :age, default: 0
attribute! :name, default: 'Beta'
end
beta_person = SubSubclass.new({})
beta_person.name
beta_person.age
⬆️ Back to Top
How to query the attributes?
All of the methods that will be explained can be used with any of the built-in extensions.
PS: We will use the class below for all of the next examples.
class Person
include Micro::Attributes
attribute :age
attribute :first_name, default: 'John'
attribute :last_name, default: 'Doe'
def initialize(options)
self.attributes = options
end
def name
"#{first_name} #{last_name}"
end
end
.attributes
Listing all the class attributes.
Person.attributes
.attribute?()
Checking the existence of some attribute.
Person.attribute?(:first_name)
Person.attribute?('first_name')
Person.attribute?('foo')
Person.attribute?(:foo)
#attribute?()
Checking the existence of some attribute in an instance.
person = Person.new(age: 20)
person.attribute?(:name)
person.attribute?('name')
person.attribute?('foo')
person.attribute?(:foo)
#attributes()
Fetching all the attributes with their values.
person1 = Person.new(age: 20)
person1.attributes
person2 = Person.new(first_name: 'Rodrigo', last_name: 'Rodrigues')
person2.attributes
#attributes(keys_as:)
Use the keys_as:
option with Symbol
/:symbol
or String
/:string
to transform the attributes hash keys.
person1 = Person.new(age: 20)
person2 = Person.new(first_name: 'Rodrigo', last_name: 'Rodrigues')
person1.attributes(keys_as: Symbol)
person2.attributes(keys_as: String)
person1.attributes(keys_as: :symbol)
person2.attributes(keys_as: :string)
#attributes(*names)
Slices the attributes to include only the given keys (in their types).
person = Person.new(age: 20)
person.attributes(:age)
person.attributes(:age, :first_name)
person.attributes('age', 'last_name')
person.attributes(:age, 'last_name')
person.attributes(:age, 'last_name', keys_as: Symbol)
#attributes([names])
As the previous example, this methods accepts a list of keys to slice the attributes.
person = Person.new(age: 20)
person.attributes([:age])
person.attributes([:age, :first_name])
person.attributes(['age', 'last_name'])
person.attributes([:age, 'last_name'])
person.attributes([:age, 'last_name'], keys_as: Symbol)
#attributes(with:, without:)
Use the with:
option to include any method value of the instance inside of the hash, and,
you can use the without:
option to exclude one or more attribute keys from the final hash.
person = Person.new(age: 20)
person.attributes(without: :age)
person.attributes(without: [:age, :last_name])
person.attributes(with: [:name], without: [:first_name, :last_name])
person.attributes(:age, with: [:name])
person.attributes(:age, with: [:name], keys_as: Symbol)
#defined_attributes
Listing all the available attributes.
person = Person.new(age: 20)
person.defined_attributes
⬆️ Back to Top
Built-in extensions
You can use the method Micro::Attributes.with()
to combine and require only the features that better fit your needs.
But, if you desire except one or more features, use the Micro::Attributes.without()
method.
Picking specific features
Micro::Attributes.with
Micro::Attributes.with(:initialize)
Micro::Attributes.with(:initialize, :keys_as_symbol)
Micro::Attributes.with(:keys_as_symbol, initialize: :strict)
Micro::Attributes.with(:diff, :initialize)
Micro::Attributes.with(:diff, initialize: :strict)
Micro::Attributes.with(:diff, :keys_as_symbol, initialize: :strict)
Micro::Attributes.with(:activemodel_validations)
Micro::Attributes.with(:activemodel_validations, :diff)
Micro::Attributes.with(:activemodel_validations, :diff, initialize: :strict)
Micro::Attributes.with(:activemodel_validations, :diff, :keys_as_symbol, initialize: :strict)
The method Micro::Attributes.with()
will raise an exception if no arguments/features were declared.
class Job
include Micro::Attributes.with()
end
Micro::Attributes.without
Picking except one or more features
Micro::Attributes.without(:diff)
Micro::Attributes.without(initialize: :strict)
Picking all the features
Micro::Attributes.with_all_features
Micro::Attributes.with(:activemodel_validations, :diff, :keys_as_symbol, initialize: :strict)
⬆️ Back to Top
Extensions
ActiveModel::Validation
extension
If your application uses ActiveModel as a dependency (like a regular Rails app). You will be enabled to use the activemodel_validations
extension.
class Job
include Micro::Attributes.with(:activemodel_validations)
attribute :id
attribute :state, default: 'sleeping'
validates! :id, :state, presence: true
end
Job.new({})
job = Job.new(id: 1)
job.id
job.state
.attribute()
options
You can use the validate
or validates
options to define your attributes. e.g.
class Job
include Micro::Attributes.with(:activemodel_validations)
attribute :id, validates: { presence: true }
attribute :state, validate: :must_be_a_filled_string
def must_be_a_filled_string
return if state.is_a?(String) && state.present?
errors.add(:state, 'must be a filled string')
end
end
⬆️ Back to Top
Diff extension
Provides a way to track changes in your object attributes.
require 'securerandom'
class Job
include Micro::Attributes.with(:initialize, :diff)
attribute :id
attribute :state, default: 'sleeping'
end
job = Job.new(id: SecureRandom.uuid())
job.id
job.state
job_running = job.with_attribute(:state, 'running')
job_running.state
job_changes = job.diff_attributes(job_running)
job_changes.present?
job_changes.blank?
job_changes.empty?
job_changes.changed?
job_changes.changed?(:id)
job_changes.changed?(:state)
job_changes.changed?(:state, from: 'sleeping', to: 'running')
job_changes.differences
⬆️ Back to Top
Initialize extension
- Creates a constructor to assign the attributes.
- Add methods to build new instances when some data was assigned.
class Job
include Micro::Attributes.with(:initialize)
attributes :id, :state
end
job_null = Job.new({})
job.id
job.state
job = Job.new(id: 1, state: 'sleeping')
job.id
job.state
new_job = job.with_attribute(:state, 'running')
new_job.id
new_job.state
new_job.equal?(job)
other_job = job.with_attributes(id: 2, state: 'killed')
other_job.id
other_job.state
other_job.equal?(job)
⬆️ Back to Top
Strict mode
- Creates a constructor to assign the attributes.
- Adds methods to build new instances when some data was assigned.
- Forbids missing keywords.
class Job
include Micro::Attributes.with(initialize: :strict)
attributes :id, :state
end
Job.new({})
job_null = Job.new(id: nil, state: nil)
job.id
job.state
job = Job.new(id: 1, state: 'sleeping')
job.id
job.state
Note: This extension works like the initialize
extension. So, look at its section to understand all of the other features.
⬆️ Back to Top
Keys as symbol extension
Disables the indifferent access requiring the declaration/usage of the attributes as symbols.
The advantage of this extension over the default behavior is because it avoids an unnecessary allocation in memory of strings. All the keys are transformed into strings in the indifferent access mode, but, with this extension, this typecasting will be avoided. So, it has a better performance and reduces the usage of memory/Garbage collector, but gives for you the responsibility to always use symbols to set/access the attributes.
class Job
include Micro::Attributes.with(:initialize, :keys_as_symbol)
attribute :id
attribute :state, default: 'sleeping'
end
job = Job.new(id: 1)
job.attributes
job.attribute?(:id)
job.attribute?('id')
job.attribute(:id)
job.attribute('id')
job.attribute!(:id)
job.attribute!('id')
As you could see in the previous example only symbols will work to do something with the attributes.
This extension also changes the diff extension
making everything (arguments, outputs) working only with symbols.
⬆️ Back to Top
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake test
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/serradura/u-attributes. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the Micro::Attributes project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.