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.
Version | Documentation |
---|---|
unreleased | https://github.com/serradura/u-case/blob/main/README.md |
2.8.0 | https://github.com/serradura/u-case/blob/v2.x/README.md |
1.2.0 | https://github.com/serradura/u-case/blob/v1.x/README.md |
- Installation
- Compatibility
- Usage
- Built-in extensions
- Development
- Contributing
- License
- Code of Conduct
Add this line to your application's Gemfile and bundle install
:
gem 'u-attributes'
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.
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 # 21
person.name # John Doe
# By design the attributes are always exposed as reader methods (getters).
# If you try to call a setter you will see a NoMethodError.
#
# person.name = 'Rodrigo'
# NoMethodError (undefined method `name=' for #<Person:0x0000... @name='John Doe', @age=21>)
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 # 20
person.name # John Doe
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
# extracting from an object
class User
attr_accessor :age, :name
end
user = User.new
user.age = 20
person = Person.new(user: user)
person.age # 20
person.name # John Doe
# extracting from a hash
another_person = Person.new(user: { age: 55, name: 'Julia Not Roberts' })
another_person.age # 55
another_person.name # Julia Not Roberts
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) # ArgumentError (missing keyword: :name)
Use this method with a valid attribute name to get its value.
person = Person.new(age: 20)
person.attribute('age') # 20
person.attribute(:name) # John Doe
person.attribute('foo') # nil
If you pass a block, it will be executed only if the attribute was valid.
person.attribute(:name) { |value| puts value } # John Doe
person.attribute('age') { |value| puts value } # 20
person.attribute('foo') { |value| puts value } # !! Nothing happened, because of the attribute doesn't exist.
Works like the #attribute
method, but it will raise an exception when the attribute doesn't exist.
person.attribute!('foo') # NameError (undefined attribute `foo)
person.attribute!('foo') { |value| value } # NameError (undefined attribute `foo)
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 # nil
person.age # 32
Note: This method can't define default values. To do this, use the
#attribute()
method.
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 # 18
person.name # John Doe
This extension enables two methods for your objects.
The #with_attribute()
and #with_attributes()
.
another_person = person.with_attribute(:age, 21)
another_person.age # 21
another_person.name # John Doe
another_person.equal?(person) # false
Use it to assign multiple attributes
other_person = person.with_attributes(name: 'Serradura', age: 32)
other_person.age # 32
other_person.name # Serradura
other_person.equal?(person) # false
If you pass a value different of a Hash, a Kind::Error will be raised.
Person.new(1) # Kind::Error (1 expected to be a kind of Hash)
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
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({}) # ArgumentError (missing keyword: :age)
An attribute with a default value can be omitted.
person_without_age = StrictPerson.new(age: nil)
person_without_age.age # nil
person_without_age.name # 'John Doe'
Note: Except for this validation the
.with(initialize: :strict)
method will works in the same ways of.with(:initialize)
.
Yes. e.g.
class Person
include Micro::Attributes.with(:initialize)
attribute :age
attribute :name, default: 'John Doe'
end
class Subclass < Person # Will preserve the parent class attributes
attribute :foo
end
instance = Subclass.new({})
instance.name # John Doe
instance.respond_to?(:age) # true
instance.respond_to?(:foo) # true
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'
alfa_person.age # nil
class SubSubclass < Subclass
attribute! :age, default: 0
attribute! :name, default: 'Beta'
end
beta_person = SubSubclass.new({})
beta_person.name # 'Beta'
beta_person.age # 0
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
Listing all the class attributes.
Person.attributes # ["age", "first_name", "last_name"]
Checking the existence of some attribute.
Person.attribute?(:first_name) # true
Person.attribute?('first_name') # true
Person.attribute?('foo') # false
Person.attribute?(:foo) # false
Checking the existence of some attribute in an instance.
person = Person.new(age: 20)
person.attribute?(:name) # true
person.attribute?('name') # true
person.attribute?('foo') # false
person.attribute?(:foo) # false
Fetching all the attributes with their values.
person1 = Person.new(age: 20)
person1.attributes # {"age"=>20, "first_name"=>"John", "last_name"=>"Doe"}
person2 = Person.new(first_name: 'Rodrigo', last_name: 'Rodrigues')
person2.attributes # {"age"=>nil, "first_name"=>"Rodrigo", "last_name"=>"Rodrigues"}
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) # {:age=>20, :first_name=>"John", :last_name=>"Doe"}
person2.attributes(keys_as: String) # {"age"=>nil, "first_name"=>"Rodrigo", "last_name"=>"Rodrigues"}
person1.attributes(keys_as: :symbol) # {:age=>20, :first_name=>"John", :last_name=>"Doe"}
person2.attributes(keys_as: :string) # {"age"=>nil, "first_name"=>"Rodrigo", "last_name"=>"Rodrigues"}
Slices the attributes to include only the given keys (in their types).
person = Person.new(age: 20)
person.attributes(:age) # {:age => 20}
person.attributes(:age, :first_name) # {:age => 20, :first_name => "John"}
person.attributes('age', 'last_name') # {"age" => 20, "last_name" => "Doe"}
person.attributes(:age, 'last_name') # {:age => 20, "last_name" => "Doe"}
# You could also use the keys_as: option to ensure the same type for all of the hash keys.
person.attributes(:age, 'last_name', keys_as: Symbol) # {:age=>20, :last_name=>"Doe"}
As the previous example, this methods accepts a list of keys to slice the attributes.
person = Person.new(age: 20)
person.attributes([:age]) # {:age => 20}
person.attributes([:age, :first_name]) # {:age => 20, :first_name => "John"}
person.attributes(['age', 'last_name']) # {"age" => 20, "last_name" => "Doe"}
person.attributes([:age, 'last_name']) # {:age => 20, "last_name" => "Doe"}
# You could also use the keys_as: option to ensure the same type for all of the hash keys.
person.attributes([:age, 'last_name'], keys_as: Symbol) # {:age=>20, :last_name=>"Doe"}
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) # {"first_name"=>"John", "last_name"=>"Doe"}
person.attributes(without: [:age, :last_name]) # {"first_name"=>"John"}
person.attributes(with: [:name], without: [:first_name, :last_name]) # {"age"=>20, "name"=>"John Doe"}
# To achieves the same output of the previous example, use the attribute names to slice only them.
person.attributes(:age, with: [:name]) # {:age=>20, "name"=>"John Doe"}
# You could also use the keys_as: option to ensure the same type for all of the hash keys.
person.attributes(:age, with: [:name], keys_as: Symbol) # {:age=>20, :name=>"John Doe"}
Listing all the available attributes.
person = Person.new(age: 20)
person.defined_attributes # ["age", "first_name", "last_name"]
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.
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() # ArgumentError (Invalid feature name! Available options: :accept, :activemodel_validations, :diff, :initialize, :keys_as_symbol)
end
Picking except one or more features
Micro::Attributes.without(:diff) # will load :activemodel_validations, :keys_as_symbol and initialize: :strict
Micro::Attributes.without(initialize: :strict) # will load :activemodel_validations, :diff and :keys_as_symbol
Micro::Attributes.with_all_features
# This method returns the same of:
Micro::Attributes.with(:activemodel_validations, :diff, :keys_as_symbol, initialize: :strict)
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({}) # ActiveModel::StrictValidationFailed (Id can't be blank)
job = Job.new(id: 1)
job.id # 1
job.state # 'sleeping'
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
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 # A random UUID generated from SecureRandom.uuid(). e.g: 'e68bcc74-b91c-45c2-a904-12f1298cc60e'
job.state # 'sleeping'
job_running = job.with_attribute(:state, 'running')
job_running.state # 'running'
job_changes = job.diff_attributes(job_running)
#-----------------------------#
# #present?, #blank?, #empty? #
#-----------------------------#
job_changes.present? # true
job_changes.blank? # false
job_changes.empty? # false
#-----------#
# #changed? #
#-----------#
job_changes.changed? # true
job_changes.changed?(:id) # false
job_changes.changed?(:state) # true
job_changes.changed?(:state, from: 'sleeping', to: 'running') # true
#----------------#
# #differences() #
#----------------#
job_changes.differences # {'state'=> {'from' => 'sleeping', 'to' => 'running'}}
- 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 # nil
job.state # nil
job = Job.new(id: 1, state: 'sleeping')
job.id # 1
job.state # 'sleeping'
##############################################
# Assigning new values to get a new instance #
##############################################
#-------------------#
# #with_attribute() #
#-------------------#
new_job = job.with_attribute(:state, 'running')
new_job.id # 1
new_job.state # running
new_job.equal?(job) # false
#--------------------#
# #with_attributes() #
#--------------------#
#
# Use it to assign multiple attributes
other_job = job.with_attributes(id: 2, state: 'killed')
other_job.id # 2
other_job.state # killed
other_job.equal?(job) # false
- 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
#-----------------------------------------------------------------------#
# The strict initialize mode will require all the keys when initialize. #
#-----------------------------------------------------------------------#
Job.new({})
# The code above will raise:
# ArgumentError (missing keywords: :id, :state)
#---------------------------#
# Samples passing some data #
#---------------------------#
job_null = Job.new(id: nil, state: nil)
job.id # nil
job.state # nil
job = Job.new(id: 1, state: 'sleeping')
job.id # 1
job.state # 'sleeping'
Note: This extension works like the
initialize
extension. So, look at its section to understand all of the other features.
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 # {:id => 1, :state => "sleeping"}
job.attribute?(:id) # true
job.attribute?('id') # false
job.attribute(:id) # 1
job.attribute('id') # nil
job.attribute!(:id) # 1
job.attribute!('id') # NameError (undefined 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.
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.
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.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the Micro::Attributes project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.