项目作者: serradura

项目描述 :
Create "immutable" objects with no setters, just getters.
高级语言: Ruby
项目地址: git://github.com/serradura/u-attributes.git
创建时间: 2019-07-02T04:54:21Z
项目社区:https://github.com/serradura/u-attributes

开源协议:MIT License

下载



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

Create “immutable” objects with no setters, just getters.





Ruby


Gem



Build Status



Maintainability



Test Coverage

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

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

Table of contents

Installation

Add this line to your application’s Gemfile and bundle install:

  1. 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.

  1. class Person
  2. include Micro::Attributes
  3. attribute :age
  4. attribute :name
  5. def initialize(name: 'John Doe', age:)
  6. @name, @age = name, age
  7. end
  8. end
  9. person = Person.new(age: 21)
  10. person.age # 21
  11. person.name # John Doe
  12. # By design the attributes are always exposed as reader methods (getters).
  13. # If you try to call a setter you will see a NoMethodError.
  14. #
  15. # person.name = 'Rodrigo'
  16. # NoMethodError (undefined method `name=' for #<Person:0x0000... @name='John Doe', @age=21>)

⬆️ Back to Top

Micro::Attributes#attributes=

This is a protected method to make easier the assignment in a constructor. e.g.

  1. class Person
  2. include Micro::Attributes
  3. attribute :age
  4. attribute :name, default: 'John Doe'
  5. def initialize(options)
  6. self.attributes = options
  7. end
  8. end
  9. person = Person.new(age: 20)
  10. person.age # 20
  11. 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.

  1. class Person
  2. include Micro::Attributes
  3. attribute :age
  4. attribute :name, default: 'John Doe'
  5. def initialize(user:)
  6. self.attributes = extract_attributes_from(user)
  7. end
  8. end
  9. # extracting from an object
  10. class User
  11. attr_accessor :age, :name
  12. end
  13. user = User.new
  14. user.age = 20
  15. person = Person.new(user: user)
  16. person.age # 20
  17. person.name # John Doe
  18. # extracting from a hash
  19. another_person = Person.new(user: { age: 55, name: 'Julia Not Roberts' })
  20. another_person.age # 55
  21. 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= method or the extensions: initialize, activemodel_validations.

  1. class Person
  2. include Micro::Attributes
  3. attribute :age
  4. attribute :name, required: true
  5. def initialize(attributes)
  6. self.attributes = attributes
  7. end
  8. end
  9. Person.new(age: 32) # ArgumentError (missing keyword: :name)

⬆️ Back to Top

Micro::Attributes#attribute

Use this method with a valid attribute name to get its value.

  1. person = Person.new(age: 20)
  2. person.attribute('age') # 20
  3. person.attribute(:name) # John Doe
  4. person.attribute('foo') # nil

If you pass a block, it will be executed only if the attribute was valid.

  1. person.attribute(:name) { |value| puts value } # John Doe
  2. person.attribute('age') { |value| puts value } # 20
  3. person.attribute('foo') { |value| puts value } # !! Nothing happened, because of the attribute doesn't exist.

⬆️ Back to Top

Micro::Attributes#attribute!

Works like the #attribute method, but it will raise an exception when the attribute doesn’t exist.

  1. person.attribute!('foo') # NameError (undefined attribute `foo)
  2. person.attribute!('foo') { |value| value } # NameError (undefined attribute `foo)

⬆️ Back to Top

How to define multiple attributes?

Use .attributes with a list of attribute names.

  1. class Person
  2. include Micro::Attributes
  3. attributes :age, :name
  4. def initialize(options)
  5. self.attributes = options
  6. end
  7. end
  8. person = Person.new(age: 32)
  9. person.name # nil
  10. person.age # 32

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.

  1. class Person
  2. include Micro::Attributes.with(:initialize)
  3. attribute :age, required: true
  4. attribute :name, default: 'John Doe'
  5. end
  6. person = Person.new(age: 18)
  7. person.age # 18
  8. person.name # John Doe

This extension enables two methods for your objects.
The #with_attribute() and #with_attributes().

#with_attribute()

  1. another_person = person.with_attribute(:age, 21)
  2. another_person.age # 21
  3. another_person.name # John Doe
  4. another_person.equal?(person) # false

#with_attributes()

Use it to assign multiple attributes

  1. other_person = person.with_attributes(name: 'Serradura', age: 32)
  2. other_person.age # 32
  3. other_person.name # Serradura
  4. other_person.equal?(person) # false

If you pass a value different of a Hash, a Kind::Error will be raised.

  1. Person.new(1) # Kind::Error (1 expected to be a kind of Hash)

⬆️ Back to Top

Defining default values to the attributes

To do this, you only need make use of the default: keyword. e.g.

  1. class Person
  2. include Micro::Attributes.with(:initialize)
  3. attribute :age
  4. attribute :name, default: 'John Doe'
  5. end

There are two 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.
  1. class Person
  2. include Micro::Attributes.with(:initialize)
  3. attribute :age, default: -> age { age&.to_i }
  4. attribute :name, default: -> name { String(name || 'John Doe').strip }
  5. 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.

  1. class StrictPerson
  2. include Micro::Attributes.with(initialize: :strict)
  3. attribute :age
  4. attribute :name, default: 'John Doe'
  5. end
  6. StrictPerson.new({}) # ArgumentError (missing keyword: :age)

An attribute with a default value can be omitted.

  1. person_without_age = StrictPerson.new(age: nil)
  2. person_without_age.age # nil
  3. 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

Is it possible to inherit the attributes?

Yes. e.g.

  1. class Person
  2. include Micro::Attributes.with(:initialize)
  3. attribute :age
  4. attribute :name, default: 'John Doe'
  5. end
  6. class Subclass < Person # Will preserve the parent class attributes
  7. attribute :foo
  8. end
  9. instance = Subclass.new({})
  10. instance.name # John Doe
  11. instance.respond_to?(:age) # true
  12. instance.respond_to?(:foo) # true

⬆️ Back to Top

.attribute!()

This method allows us to redefine the attributes default data that was defined in the parent class. e.g.

  1. class AnotherSubclass < Person
  2. attribute! :name, default: 'Alfa'
  3. end
  4. alfa_person = AnotherSubclass.new({})
  5. alfa_person.name # 'Alfa'
  6. alfa_person.age # nil
  7. class SubSubclass < Subclass
  8. attribute! :age, default: 0
  9. attribute! :name, default: 'Beta'
  10. end
  11. beta_person = SubSubclass.new({})
  12. beta_person.name # 'Beta'
  13. beta_person.age # 0

⬆️ 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.

  1. class Person
  2. include Micro::Attributes
  3. attribute :age
  4. attribute :first_name, default: 'John'
  5. attribute :last_name, default: 'Doe'
  6. def initialize(options)
  7. self.attributes = options
  8. end
  9. def name
  10. "#{first_name} #{last_name}"
  11. end
  12. end

.attributes

Listing all the class attributes.

  1. Person.attributes # ["age", "first_name", "last_name"]

.attribute?()

Checking the existence of some attribute.

  1. Person.attribute?(:first_name) # true
  2. Person.attribute?('first_name') # true
  3. Person.attribute?('foo') # false
  4. Person.attribute?(:foo) # false

#attribute?()

Checking the existence of some attribute in an instance.

  1. person = Person.new(age: 20)
  2. person.attribute?(:name) # true
  3. person.attribute?('name') # true
  4. person.attribute?('foo') # false
  5. person.attribute?(:foo) # false

#attributes()

Fetching all the attributes with their values.

  1. person1 = Person.new(age: 20)
  2. person1.attributes # {"age"=>20, "first_name"=>"John", "last_name"=>"Doe"}
  3. person2 = Person.new(first_name: 'Rodrigo', last_name: 'Rodrigues')
  4. person2.attributes # {"age"=>nil, "first_name"=>"Rodrigo", "last_name"=>"Rodrigues"}

#attributes(keys_as:)

Use the keys_as: option with Symbol/:symbol or String/:string to transform the attributes hash keys.

  1. person1 = Person.new(age: 20)
  2. person2 = Person.new(first_name: 'Rodrigo', last_name: 'Rodrigues')
  3. person1.attributes(keys_as: Symbol) # {:age=>20, :first_name=>"John", :last_name=>"Doe"}
  4. person2.attributes(keys_as: String) # {"age"=>nil, "first_name"=>"Rodrigo", "last_name"=>"Rodrigues"}
  5. person1.attributes(keys_as: :symbol) # {:age=>20, :first_name=>"John", :last_name=>"Doe"}
  6. person2.attributes(keys_as: :string) # {"age"=>nil, "first_name"=>"Rodrigo", "last_name"=>"Rodrigues"}

#attributes(*names)

Slices the attributes to include only the given keys (in their types).

  1. person = Person.new(age: 20)
  2. person.attributes(:age) # {:age => 20}
  3. person.attributes(:age, :first_name) # {:age => 20, :first_name => "John"}
  4. person.attributes('age', 'last_name') # {"age" => 20, "last_name" => "Doe"}
  5. person.attributes(:age, 'last_name') # {:age => 20, "last_name" => "Doe"}
  6. # You could also use the keys_as: option to ensure the same type for all of the hash keys.
  7. person.attributes(:age, 'last_name', keys_as: Symbol) # {:age=>20, :last_name=>"Doe"}

#attributes([names])

As the previous example, this methods accepts a list of keys to slice the attributes.

  1. person = Person.new(age: 20)
  2. person.attributes([:age]) # {:age => 20}
  3. person.attributes([:age, :first_name]) # {:age => 20, :first_name => "John"}
  4. person.attributes(['age', 'last_name']) # {"age" => 20, "last_name" => "Doe"}
  5. person.attributes([:age, 'last_name']) # {:age => 20, "last_name" => "Doe"}
  6. # You could also use the keys_as: option to ensure the same type for all of the hash keys.
  7. person.attributes([:age, 'last_name'], keys_as: Symbol) # {:age=>20, :last_name=>"Doe"}

#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.

  1. person = Person.new(age: 20)
  2. person.attributes(without: :age) # {"first_name"=>"John", "last_name"=>"Doe"}
  3. person.attributes(without: [:age, :last_name]) # {"first_name"=>"John"}
  4. person.attributes(with: [:name], without: [:first_name, :last_name]) # {"age"=>20, "name"=>"John Doe"}
  5. # To achieves the same output of the previous example, use the attribute names to slice only them.
  6. person.attributes(:age, with: [:name]) # {:age=>20, "name"=>"John Doe"}
  7. # You could also use the keys_as: option to ensure the same type for all of the hash keys.
  8. person.attributes(:age, with: [:name], keys_as: Symbol) # {:age=>20, :name=>"John Doe"}

#defined_attributes

Listing all the available attributes.

  1. person = Person.new(age: 20)
  2. person.defined_attributes # ["age", "first_name", "last_name"]

⬆️ 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

  1. Micro::Attributes.with(:initialize)
  2. Micro::Attributes.with(:initialize, :keys_as_symbol)
  3. Micro::Attributes.with(:keys_as_symbol, initialize: :strict)
  4. Micro::Attributes.with(:diff, :initialize)
  5. Micro::Attributes.with(:diff, initialize: :strict)
  6. Micro::Attributes.with(:diff, :keys_as_symbol, initialize: :strict)
  7. Micro::Attributes.with(:activemodel_validations)
  8. Micro::Attributes.with(:activemodel_validations, :diff)
  9. Micro::Attributes.with(:activemodel_validations, :diff, initialize: :strict)
  10. 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.

  1. class Job
  2. include Micro::Attributes.with() # ArgumentError (Invalid feature name! Available options: :accept, :activemodel_validations, :diff, :initialize, :keys_as_symbol)
  3. end

Micro::Attributes.without

Picking except one or more features

  1. Micro::Attributes.without(:diff) # will load :activemodel_validations, :keys_as_symbol and initialize: :strict
  2. Micro::Attributes.without(initialize: :strict) # will load :activemodel_validations, :diff and :keys_as_symbol

Picking all the features

  1. Micro::Attributes.with_all_features
  2. # This method returns the same of:
  3. 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.

  1. class Job
  2. include Micro::Attributes.with(:activemodel_validations)
  3. attribute :id
  4. attribute :state, default: 'sleeping'
  5. validates! :id, :state, presence: true
  6. end
  7. Job.new({}) # ActiveModel::StrictValidationFailed (Id can't be blank)
  8. job = Job.new(id: 1)
  9. job.id # 1
  10. job.state # 'sleeping'

.attribute() options

You can use the validate or validates options to define your attributes. e.g.

  1. class Job
  2. include Micro::Attributes.with(:activemodel_validations)
  3. attribute :id, validates: { presence: true }
  4. attribute :state, validate: :must_be_a_filled_string
  5. def must_be_a_filled_string
  6. return if state.is_a?(String) && state.present?
  7. errors.add(:state, 'must be a filled string')
  8. end
  9. end

⬆️ Back to Top

Diff extension

Provides a way to track changes in your object attributes.

  1. require 'securerandom'
  2. class Job
  3. include Micro::Attributes.with(:initialize, :diff)
  4. attribute :id
  5. attribute :state, default: 'sleeping'
  6. end
  7. job = Job.new(id: SecureRandom.uuid())
  8. job.id # A random UUID generated from SecureRandom.uuid(). e.g: 'e68bcc74-b91c-45c2-a904-12f1298cc60e'
  9. job.state # 'sleeping'
  10. job_running = job.with_attribute(:state, 'running')
  11. job_running.state # 'running'
  12. job_changes = job.diff_attributes(job_running)
  13. #-----------------------------#
  14. # #present?, #blank?, #empty? #
  15. #-----------------------------#
  16. job_changes.present? # true
  17. job_changes.blank? # false
  18. job_changes.empty? # false
  19. #-----------#
  20. # #changed? #
  21. #-----------#
  22. job_changes.changed? # true
  23. job_changes.changed?(:id) # false
  24. job_changes.changed?(:state) # true
  25. job_changes.changed?(:state, from: 'sleeping', to: 'running') # true
  26. #----------------#
  27. # #differences() #
  28. #----------------#
  29. job_changes.differences # {'state'=> {'from' => 'sleeping', 'to' => 'running'}}

⬆️ Back to Top

Initialize extension

  1. Creates a constructor to assign the attributes.
  2. Add methods to build new instances when some data was assigned.
  1. class Job
  2. include Micro::Attributes.with(:initialize)
  3. attributes :id, :state
  4. end
  5. job_null = Job.new({})
  6. job.id # nil
  7. job.state # nil
  8. job = Job.new(id: 1, state: 'sleeping')
  9. job.id # 1
  10. job.state # 'sleeping'
  11. ##############################################
  12. # Assigning new values to get a new instance #
  13. ##############################################
  14. #-------------------#
  15. # #with_attribute() #
  16. #-------------------#
  17. new_job = job.with_attribute(:state, 'running')
  18. new_job.id # 1
  19. new_job.state # running
  20. new_job.equal?(job) # false
  21. #--------------------#
  22. # #with_attributes() #
  23. #--------------------#
  24. #
  25. # Use it to assign multiple attributes
  26. other_job = job.with_attributes(id: 2, state: 'killed')
  27. other_job.id # 2
  28. other_job.state # killed
  29. other_job.equal?(job) # false

⬆️ Back to Top

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.
  1. class Job
  2. include Micro::Attributes.with(initialize: :strict)
  3. attributes :id, :state
  4. end
  5. #-----------------------------------------------------------------------#
  6. # The strict initialize mode will require all the keys when initialize. #
  7. #-----------------------------------------------------------------------#
  8. Job.new({})
  9. # The code above will raise:
  10. # ArgumentError (missing keywords: :id, :state)
  11. #---------------------------#
  12. # Samples passing some data #
  13. #---------------------------#
  14. job_null = Job.new(id: nil, state: nil)
  15. job.id # nil
  16. job.state # nil
  17. job = Job.new(id: 1, state: 'sleeping')
  18. job.id # 1
  19. 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

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.

  1. class Job
  2. include Micro::Attributes.with(:initialize, :keys_as_symbol)
  3. attribute :id
  4. attribute :state, default: 'sleeping'
  5. end
  6. job = Job.new(id: 1)
  7. job.attributes # {:id => 1, :state => "sleeping"}
  8. job.attribute?(:id) # true
  9. job.attribute?('id') # false
  10. job.attribute(:id) # 1
  11. job.attribute('id') # nil
  12. job.attribute!(:id) # 1
  13. 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.

⬆️ 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.