Create "immutable" objects. No setters, just getters!

Create "immutable" objects. No setters, just getters!


Ruby Gem Build Status Maintainability Test Coverage

This gem allows you to define "immutable" objects, and your objects will have only getters and no setters. So, if you change [[1](#with_attribute)] [[2](#with_attributes)] some object attribute, you will have a new object instance. That is, you transform the object instead of modifying it. # Table of contents - [Installation](#installation) - [Compatibility](#compatibility) - [Usage](#usage) - [How to define attributes?](#how-to-define-attributes) - [`Micro::Attributes#attributes=`](#microattributesattributes) - [How to extract attributes from an object or hash?](#how-to-extract-attributes-from-an-object-or-hash) - [Is it possible to define an attribute as required?](#is-it-possible-to-define-an-attribute-as-required) - [`Micro::Attributes#attribute`](#microattributesattribute) - [`Micro::Attributes#attribute!`](#microattributesattribute-1) - [How to define multiple attributes?](#how-to-define-multiple-attributes) - [`Micro::Attributes.with(:initialize)`](#microattributeswithinitialize) - [`#with_attribute()`](#with_attribute) - [`#with_attributes()`](#with_attributes) - [Defining default values to the attributes](#defining-default-values-to-the-attributes) - [The strict initializer](#the-strict-initializer) - [Is it possible to inherit the attributes?](#is-it-possible-to-inherit-the-attributes) - [`.attribute!()`](#attribute) - [How to query the attributes?](#how-to-query-the-attributes) - [Built-in extensions](#built-in-extensions) - [Picking specific features](#picking-specific-features) - [`Micro::Attributes.with`](#microattributeswith) - [`Micro::Attributes.without`](#microattributeswithout) - [Picking all the features](#picking-all-the-features) - [Extensions](#extensions) - [`ActiveModel::Validation` extension](#activemodelvalidation-extension) - [`.attribute()` options](#attribute-options) - [Diff extension](#diff-extension) - [Initialize extension](#initialize-extension) - [Strict mode](#strict-mode) - [Development](#development) - [Contributing](#contributing) - [License](#license) - [Code of Conduct](#code-of-conduct) # Installation Add this line to your application's Gemfile and `bundle install`: ```ruby gem 'u-attributes' ``` # Compatibility | u-attributes | branch | ruby | activemodel | | -------------- | ------- | -------- | ------------- | | 2.1.0 | main | >= 2.2.0 | >= 3.2, < 6.1 | | 1.2.0 | v1.x | >= 2.2.0 | >= 3.2, < 6.1 | > **Note**: The activemodel is an optional dependency, this module [can be enabled](#activemodelvalidation-extension) to validate the attributes. [⬆️ Back to Top](#table-of-contents-) # Usage ## How to define attributes? By default, you must define the class constructor. ```ruby 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 #) ``` [⬆️ Back to Top](#table-of-contents-) ### `Micro::Attributes#attributes=` This is a protected method to make easier the assignment in a constructor. e.g. ```ruby 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 ``` #### How to extract attributes from an object or hash? 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`. ```ruby 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 ``` #### 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=`](#microattributesattributes) method or the extensions: [initialize](#initialize-extension), [activemodel_validations](#activemodelvalidation-extension). ```ruby 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) ``` [⬆️ Back to Top](#table-of-contents-) ### `Micro::Attributes#attribute` Use this method with a valid attribute name to get its value. ```ruby 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. ```ruby 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. ``` [⬆️ Back to Top](#table-of-contents-) ### `Micro::Attributes#attribute!` Works like the `#attribute` method, but it will raise an exception when the attribute doesn't exist. ```ruby person.attribute!('foo') # NameError (undefined attribute `foo) person.attribute!('foo') { |value| value } # NameError (undefined attribute `foo) ``` [⬆️ Back to Top](#table-of-contents-) ## How to define multiple attributes? Use `.attributes` with a list of attribute names. ```ruby 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. [⬆️ Back to Top](#table-of-contents-) ## `Micro::Attributes.with(:initialize)` Use `Micro::Attributes.with(:initialize)` to define a constructor to assign the attributes. e.g. ```ruby 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()`. ### `#with_attribute()` ```ruby another_person = person.with_attribute(:age, 21) another_person.age # 21 another_person.name # John Doe another_person.equal?(person) # false ``` ### `#with_attributes()` Use it to assign multiple attributes ```ruby 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. ```ruby Person.new(1) # Kind::Error (1 expected to be a kind of Hash) ``` [⬆️ Back to Top](#table-of-contents-) ## Defining default values to the attributes To do this, you only need make use of the `default:` keyword. e.g. ```ruby class Person include Micro::Attributes.with(:initialize) attribute :age attribute :name, default: 'John Doe' end ``` There are 3 different strategies to define default values. 1. Pass a regular object, like in the previous example. 2. Pass a `proc`/`lambda`, and if it has an argument you will receive the attribute value to do something before assign it. 3. Pass a **callable**, that is, a `class`, `module` or `instance` which responds to the `call` method. The behavior will be like the previous item (`proc`/`lambda`). ```ruby 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](#table-of-contents-) ## 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](#is-it-possible-to-define-an-attribute-as-required). ```ruby 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. ``` ruby 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)`. [⬆️ Back to Top](#table-of-contents-) ## Is it possible to inherit the attributes? Yes. e.g. ```ruby 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 ``` [⬆️ Back to Top](#table-of-contents-) ### `.attribute!()` This method allows us to redefine the attributes default data that was defined in the parent class. e.g. ```ruby 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 ``` [⬆️ Back to Top](#table-of-contents-) ## How to query the attributes? ```ruby class Person include Micro::Attributes attribute :age attribute :name, default: 'John Doe' def initialize(options) self.attributes = options end end #---------------# # .attributes() # #---------------# Person.attributes # ['name', 'age'] #---------------# # .attribute?() # #---------------# Person.attribute?(:name) # true Person.attribute?('name') # true Person.attribute?('foo') # false Person.attribute?(:foo) # false # --- person = Person.new(age: 20) #---------------------# # #defined_attributes # #---------------------# person.defined_attributes # ['name', 'age'] #---------------# # #attribute?() # #---------------# person.attribute?(:name) # true person.attribute?('name') # true person.attribute?('foo') # false person.attribute?(:foo) # false #---------------# # #attributes() # #---------------# person.attributes # {'age'=>20, 'name'=>'John Doe'} Person.new(name: 'John').attributes # {'age'=>nil, 'name'=>'John'} #---------------------# # #attributes(*names) # #---------------------# # Slices the attributes to include only the given keys. # Returns a hash containing the given keys (in their types). person.attributes(:age) # {age: 20} person.attributes(:age, :name) # {age: 20, name: 'John Doe'} person.attributes('age', 'name') # {'age'=>20, 'name'=>'John Doe'} ``` [⬆️ Back to Top](#table-of-contents-) # 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` ```ruby Micro::Attributes.with(:initialize) Micro::Attributes.with(initialize: :strict) Micro::Attributes.with(:diff, :initialize) Micro::Attributes.with(:diff, initialize: :strict) Micro::Attributes.with(:activemodel_validations) Micro::Attributes.with(:activemodel_validations, :diff) Micro::Attributes.with(:activemodel_validations, :diff, initialize: :strict) ``` The method `Micro::Attributes.with()` will raise an exception if no arguments/features were declared. ```ruby class Job include Micro::Attributes.with() # ArgumentError (Invalid feature name! Available options: :activemodel_validations, :diff, :initialize) end ``` ### `Micro::Attributes.without` Picking *except* one or more features ```ruby Micro::Attributes.without(:diff) # will load :activemodel_validations and initialize: :strict Micro::Attributes.without(initialize: :strict) # will load :activemodel_validations and :diff ``` ## Picking all the features ```ruby Micro::Attributes.with_all_features ``` [⬆️ Back to Top](#table-of-contents-) ## 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. ```ruby 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' ``` #### `.attribute()` options You can use the `validate` or `validates` options to define your attributes. e.g. ```ruby 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](#table-of-contents-) ### Diff extension Provides a way to track changes in your object attributes. ```ruby 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'}} ``` [⬆️ Back to Top](#table-of-contents-) ### Initialize extension 1. Creates a constructor to assign the attributes. 2. Add methods to build new instances when some data was assigned. ```ruby 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 ``` [⬆️ Back to Top](#table-of-contents-) #### Strict mode 1. Creates a constructor to assign the attributes. 2. Adds methods to build new instances when some data was assigned. 3. **Forbids missing keywords**. ```ruby 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. [⬆️ Back to Top](#table-of-contents-) # 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](https://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](http://contributor-covenant.org) code of conduct. # License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). # 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](https://github.com/serradura/u-attributes/blob/main/CODE_OF_CONDUCT.md).