![Ruby](https://img.shields.io/badge/ruby-2.2+-ruby.svg?colorA=99004d&colorB=cc0066) [![Gem](https://img.shields.io/gem/v/u-attributes.svg?style=flat-square)](https://rubygems.org/gems/u-attributes) [![Build Status](https://travis-ci.com/serradura/u-attributes.svg?branch=main)](https://travis-ci.com/serradura/u-attributes) [![Maintainability](https://api.codeclimate.com/v1/badges/b562e6b877a9edf4dbf6/maintainability)](https://codeclimate.com/github/serradura/u-attributes/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/b562e6b877a9edf4dbf6/test_coverage)](https://codeclimate.com/github/serradura/u-attributes/test_coverage) μ-attributes (Micro::Attributes) ================================ 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) - [`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.0.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? ```ruby # By default you must to 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 #) ``` [⬆️ 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 ``` [⬆️ 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 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. e.g. ```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) #---------------# # #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).