Bronze
A composable application toolkit, providing data entities and collections, transforms, contract-based validations and pre-built operations. Architecture agnostic for easy integration with other toolkits or frameworks.
Bronze defines the following components:
- Entities - Data structures with defined attributes.
- Transforms - Map values or objects to and from a normal form.
About
Compatibility
Bronze is tested against Ruby (MRI) 2.4 through 2.6.
Documentation
Method and class documentation is available courtesy of RubyDoc.
Documentation is generated using YARD, and can be generated locally using the yard
gem.
License
Copyright (c) 2018 Rob Smith
Bronze is released under the MIT License.
Contribute
The canonical repository for this gem is located at https://github.com/sleepingkingstudios/bronze.
To report a bug or submit a feature request, please use the Issue Tracker.
To contribute code, please fork the repository, make the desired updates, and then provide a Pull Request. Pull requests must include appropriate tests for consideration, and all code must be properly formatted.
Dedication
This project is dedicated to the memory of my grandfather, who taught me the joy of flight.
Entities
require 'bronze/entity'
An entity is a data object. Each entity class can define attributes, including a primary key. Entities can be normalized, which transforms the entity to a hash of data values or vice versa.
class Book < Bronze::Entity
attribute :title, String
end
# Creating an entity.
book = Book.new
book.title => nil
# Creating an entity with attributes.
book = Book.new(title: 'The Hobbit')
book.title => 'The Hobbit'
# Updating an entity's attributes.
book.title = 'The Silmarillion'
book.title => 'The Silmarillion'
Attributes
require 'bronze/entities/attributes'
An entity class defines zero or more attributes, which represent the data stored in the entity. Each attribute has a name, a type, and properties, which are stored as metadata and determine how the attribute is read and updated.
You can also define a thin entity class by including the Attributes module:
class ThinEntity
include Bronze::Entities::Attributes
end
::attribute Class Method
The ::attribute
class method is used to define attributes for an entity class. For example, the following code defines the :title
entity for our Book
entity class.
class Book < Bronze::Entity
attribute :title, String
end
The ::attribute
method requires, at minimum, the name of the attribute (can be either a String or Symbol) and the type of the attribute value. The name determines how the attribute can be read and written. For example, since we have defined a :title
attribute on Book
, then each instance of Book
will have a #title
reader and a #title=
writer method.
The attribute type is used for validations, and when normalizing or denormalizing the entity data.
You can pass additional options to ::attribute
; see below, starting at :allow_nil Option.
::attributes Class Method
The attributes defined for an entity class are stored as metadata, and is accessible via the ::attributes
class method. This method returns a hash, with the attribute name (as a Symbol) as the hash key and a value of the corresponding metadata. For our Book class, this will look like the following.
# Listing all defined attributes.
Book.attributes => { title: #<Bronze::Entities::Attributes::Metadata> }
# Metadata for a specific attribute.
metadata = Book.attributes[:title]
metadata.name #=> :title
metadata.type #=> String
metadata.options #=> {}
The metadata also provides helper methods for the attribute options:
= Book.attributes[:title]
.allow_nil? #=> false
.default? #=> false
.read_only? #=> false
::each_attribute Class Method
As an alternative, the ::each_attribute
method allows you to iterate through the attributes defined for an entity class. If no block is given, it returns an Enumerator, otherwise, it yields the name and metadata of each defined attribute to the block.
Book.each_attribute { |name, | puts name, . }
Using ::each_attribute
is recommended over ::attributes
where possible.
#== Operator
An entity can be compared with other entities or with a hash of attributes.
If the entity is compared to a hash, then the #==
operator will return true if the hash is equal to the entity's attributes.
book = Book.new(title: 'The Hobbit')
book == {} #=> false
book == { title: 'The Silmarillion' } #=> false
book == { title: 'The Hobbit' } #=> true
If the entity is compared to another object, then the #==
operator will return true if and only if the other object has the same class (not a subclass) and the same attributes.
# Comparing with the same class but different attributes.
book == Book.new #=> false
# Comparing with a different class but the same attributes.
book == Periodical.new(title: 'The Hobbit') #=> false
# Comparing with the same class and attributes.
book == Book.new(title: 'The Hobbit') #=> true
#assign_attributes Method
The #assign_attributes
method updates the entity with the given attributes. Any attributes that are not in the given hash are unchanged, as are any attributes that are flagged as read-only.
class Book < Bronze::Entity
attribute :title, String
attribute :subtitle, String
attribute :isbn, String, read_only: true
end
book = Book.new(
title: 'The Hobbit',
subtitle: 'There And Back Again',
isbn: '123-4-56-789012-3'
)
book.assign_attributes(
subtitle: 'The Desolation of Smaug',
isbn: '098-7-65-432109-8'
)
# The title is unchanged because it was not in the attributes hash.
book.title #=> 'The Hobbit'
# The subtitle is updated.
book.subtitle #=> 'The Desolation of Smaug'
# The ISBN is unchanged because it is read-only.
book.isbn #=> '123-4-56-789012-3'
If the hash includes keys that do not correspond to attributes, it will raise an ArgumentError.
book.assign_attributes(banned_date: Date.today) #=> raises ArgumentError
#attribute? Method
The #attribute?
method tests whether the entity defines the given attribute, which can be either a String or Symbol.
class Book < Bronze::Entity
attribute :title, String
end
book = Book.new
# With a valid attribute name.
book.attribute?('title') #=> true
book.attribute?(:title) #=> true
# With an invalid attribute name.
book.attribute?(:banned_date) #=> false
#attributes Method
The #attributes
method returns the current values of each defined attribute.
class Book < Bronze::Entity
attribute :title, String
end
book = Book.new
book.attributes #=> { title: nil }
book = Book.new(title: 'The Hobbit')
book.attributes #=> { title: 'The Hobbit' }
#attributes= Method
The #attributes=
method sets the attributes to the given hash, even if the attribute is flagged as read-only. Any attributes that are not in the hash are set to nil.
Generally, the #assign_attributes
method is preferred for updating attributes.
class Book < Bronze::Entity
attribute :title, String
attribute :subtitle, String
attribute :isbn, String, read_only: true
end
book = Book.new(
title: 'The Hobbit',
subtitle: 'There And Back Again',
isbn: '123-4-56-789012-3'
)
book.attributes = {
subtitle: 'The Desolation of Smaug',
isbn: '098-7-65-432109-8'
}
# The title is set to nil because it was not in the attributes hash.
book.title #=> nil
# The subtitle is updated.
book.subtitle #=> 'The Desolation of Smaug'
# The ISBN is updated, even though it is read-only.
book.isbn #=> '098-7-65-432109-8'
If the hash includes keys that do not correspond to attributes, it will raise an ArgumentError.
book.attributes = { banned_date: Date.today } #=> raises ArgumentError
#get_attribute Method
The #get_attribute
method returns the current value of the given attribute, which can be either a String or a Symbol.
class Book < Bronze::Entity attribute :title, String end
book = Book.new(title: 'The Hobbit') book.get_attribute('title') => 'The Hobbit' book.get_attribute(:title) => 'The Hobbit'
If the named attribute is not a valid attribute for the entity, it will raise an ArgumentError.
book.get_attribute(:banned_date) #=> raises ArgumentError
#set_attribute Method
The #set_attribute
method updates the attribute to the given value. The attribute name must be either a String or a Symbol.
class Book < Bronze::Entity attribute :title, String end
book = Book.new(title: 'The Hobbit') book.set_attribute(:title, 'The Silmarillion') book.title => 'The Silmarillion'
If the named attribute is not a valid attribute for the entity, it will raise an ArgumentError.
book.get_attribute(:banned_date, Date.today) #=> raises ArgumentError
:allow_nil Option
The :allow_nil
option marks the attribute as permitting nil
values. This flag is used in validations.
class Book
attribute :subtitle, String, allow_nil: true
end
= Book.attributes[:subtitle]
.allow_nil? #=> true
:default Option
The :default
option provides a default value or proc to pre-populate the attribute when creating an entity. Unless this option is used, the initial value of the entity will be nil
.
When the default is a value, then new instances of the entity class will pre-populate the attribute with that value.
class Book
attribute :introduction,
String,
default: 'It was a dark and stormy night.'
end
book = Book.new
book.introduction #=> 'It was a dark and stormy night.'
When the default is a block, then the block will be called each time the entity class is instantiated, setting the attribute to the value returned from the block.
class Book
next_index = 0
attribute :index, Integer, default: -> { next_index += 1 }
end
book = Book.new
book.index #=> 1
book = Book.new
book.index #=> 2
:read_only Option
The :read_only
option marks the attribute as being read-only, i.e. written to only once (typically when the entity is initialized). An entity with this flag set will mark the writer method as private, and will not be updated by the #assign_attributes
or #set_attribute
methods.
class Book
attribute :isbn, String, read_only: true
end
= Book.attributes[:isbn]
.read_only? #=> true
book = Book.new(isbn: '123-4-56-789012-3')
book.isbn #=> '123-4-56-789012-3'
# Setting the value with a writer method.
book.isbn = '098-7-65-432109-8' #=> raises NoMethodError
# Setting the value with #assign_attributes.
book.assign_attributes(isbn: '098-7-65-432109-8')
book.isbn #=> '123-4-56-789012-3'
# Setting the value with #set_attribute.
book.set_attribute(:isbn, '098-7-65-432109-8')
book.isbn #=> '123-4-56-789012-3'
:transform Option
The :transform
option sets the transform used to convert the attribute to and
from a normal form. This is used for normalization, e.g. converting the entity to a portable form, and for serializing the entity to and from a data store.
Most attributes do not require a transform, and are unchanged during normalization/serialization since most data stores will natively support that data type. For select builtin types, there are default transforms defined (see Attribute Transforms, below). Finally, the :transform
option lets you set the transform for the current attribute, whether that is to override an existing default or to support a custom data type.
class Point < Struct.new(:x, :y)
class PointTransform < Bronze::Transform
def denormalize(coords)
Point.new(*Array(coords))
end
def normalize(point)
[point.x, point.y]
end
end
class Map
attribute :treasure, Point, transform: PointTransform
end
point = Point.new(3, 4)
map = Map.new(point: point)
:default_transform Option
The :default_transform
option flags the transform as a default. Default transforms can be skipped when normalizing the entity (see Normalization, below).
Normalization
require 'bronze/entities/normalization'
An entity class can define normalization helper methods, which convert an entity to a hash of data values and vice versa.
A normalized value is one of the following:
- A literal value:
nil
true
orfalse
- A
String
- An
Integer
- A
Float
- An
Array
of normalized items - A
Hash
withString
keys and with normalized values
Any other values should be converted to a normalized value, e.g. by setting a :transform option when defining the attribute.
::denormalize Class Method
The ::denormalize
class method converts a normalized hash to an entity instance.
class Book < Bronze::Entity
attribute :title, String
attribute :subtitle, String, allow_nil: true
attribute :isbn, String, read_only: true
end
attributes = {
title: 'Journey To The West',
isbn: '123-4-56-789012-3'
}
book = Book.denormalize(attributes)
book.class #=> Book
book.title #=> 'Journey To The West'
book.subtitle #=> nil
book.isbn #=> '123-4-56-789012-3'
When an attribute has a defined transform (either from an attribute transform or by defining the :transform option), then that transform is used when creating the entity from the data hash.
class Periodical
attribute :title, String
attribute :issue, Integer
attribute :date, DateTime
end
attributes = {
title: 'Triskadecaphobia Today',
issue: 13,
date: '2013-10-03T13:13:13+1300'
}
periodical = Periodical.denormalize(attributes)
periodical.class #=> Periodical
periodical.title #=> 'Triskadecaphobia Today'
periodical.issue #=> 13
periodical.date #=> #<DateTime: 2013-10-03T13:13:13+13:00>
#normalize Method
The #normalize
method converts an entity instance to a normalized hash.
class Book < Bronze::Entity
attribute :title, String
attribute :subtitle, String, allow_nil: true
attribute :isbn, String, read_only: true
end
attributes = {
title: 'Journey To The West',
isbn: '123-4-56-789012-3'
}
book = Book.new(attributes)
hash = book.normalize
hash.class #=> Hash
hash['title'] #=> 'Journey To The West'
hash['subtitle'] #=> nil
hash['isbn'] #=> '123-4-56-789012-3'
When an attribute has a defined transform (either from an attribute transform or by defining the :transform option), then that transform is used when generating the entity from the data hash.
class Periodical
attribute :title, String
attribute :issue, Integer
attribute :date, DateTime
end
attributes = {
title: 'Triskadecaphobia Today',
issue: 13,
date: DateTime.new(2013, 10, 3, 13, 13, 13, '+13:00')
}
periodical = Periodical.new(attributes)
hash = periodical.normalize
hash.class #=> Hash
hash['title'] #=> 'Triskadecaphobia Today'
hash['issue'] #=> 13
hash['date'] #=> '2013-10-03T13:13:13+1300'
:permit Option
If the :permit
option is set, then the given class or classes are normalized as-is, rather than by applying a transform. This can be useful when the destination has native support for certain data types, such as an ORM for a SQL database natively converting date and time objects.
attributes = {
title: 'Triskadecaphobia Today',
issue: 13,
date: DateTime.new(2013, 10, 3, 13, 13, 13, '+13:00')
}
periodical = Periodical.new(attributes)
hash = periodical.normalize(permit: DateTime)
hash.class #=> Hash
hash['title'] #=> 'Triskadecaphobia Today'
hash['issue'] #=> 13
hash['date'] #=> #<DateTime: 2013-10-03T13:13:13+13:00>
Only default transforms can be skipped, i.e. the built-in default transforms for BigDecimal
, Date
, DateTime
, Symbol
, and Time
, or any attributes with the :default_transform
option set.
Primary Keys
require 'bronze/entities/primary_key'
An entity class can define a primary key attribute, which serves as a unique identifier for each entity. A primary key never allows for nil
values, is read-only
, and has additional protections against being overwritten (for example, by the #attributes=
method).
Since the primary key is an attribute, defining a primary key requires both the Attributes module and the PrimaryKey module.
class ThinEntity
include Bronze::Entities::Attributes
include Bronze::Entities::PrimaryKey
end
Some predefined primary key solutions are available; see below, starting at PrimaryKeys: UUID.
::define_primary_key Class Method
The ::define_primary_key
class method is used to define a primary key for an entity class and its descendants.
class Book
define_primary_key :id, String, default: -> { SecureRandom.uuid }
end
As with defining an attribute, defining a primary key requires the name and object type of the key. In addition, a default block must be provided for generating the primary key. In this case, we are setting the primary key to :id
, which is a String
generated by calling SecureRandom.uuid
. This value is automatically generated when the entity is instantiated, unless an id
value is explicitly passed into ::new
.
Internally, this delegates to calling ::attribute
. This means our #id
accessor is defined for us (but not #id=
, since the primary key is read-only). Like any other attribute, the primary key will appear in #attributes
, can be accessed via #get_attribute
, and we can access the metadata via the ::attributes
class method.
::primary_key Class Method
The ::primary_key
class method returns the metadata for our primary key attribute directly, without having to go through ::attributes
. This will return an instance of Bronze::Entities::Attributes::Metadata
If a primary key is not defined for the entity class, it will return nil
.
For example, the following code will return the name of the primary key for our Book class:
Book.primary_key.name
#primary_key Method
The #primary_key
method returns the value of the primary key for the entity. This can be useful when different entities may use different attributes as their primary keys, such as applications using multiple datastores or with legacy data.
book = Book.new(id: '7c582500-2b33-4b41-bffc-68231c23949a')
book.id #=> '7c582500-2b33-4b41-bffc-68231c23949a'
book.primary_key #=> '7c582500-2b33-4b41-bffc-68231c23949a'
Primary Keys: UUID
require 'bronze/entities/primary_keys/uuid'
A common format for primary keys is the UUID, or Universally unique identifier (also known as the GUID). Each UUID is unique for all practical purposes, even distributed across different servers or processes.
The PrimaryKeys::Uuid
module simplifies defining a UUID-based primary key by overriding the ::define_primary_key
class method (see below). It can be included in a subclass of Bronze::Entity
, or directly in any class that includes Bronze::Entities::Attributes
.
# Including in a subclass of Bronze::Entity.
class Book < Bronze::Entity
include Bronze::Entities::PrimaryKeys::Uuid
end
# Including directly in a custom entity class.
class Periodical
include Bronze::Entities::Attributes
include Bronze::Entities::PrimaryKeys::Uuid
end
A UUID is represented in Bronze by its string representation, which looks something like this: "6891120c-c018-4060-a8b1-22d0278003f8"
. Generation is delegated to the SecureRandom.uuid
method.
::define_primary_key Class Method
The ::define_primary_key
class method is used to define a UUID primary key. Both the object type and the default generation are handled, so all that is required is the name of the primary key.
class Book < Bronze::Entity
include Bronze::Entities::PrimaryKeys::Uuid
define_primary_key :id
end
Transforms
require 'bronze/transform'
A transform represents a mapping between one type of object to another.
class Point < Struct.new(:x, :y)
class PointTransform < Bronze::Transform
def denormalize(coords)
Point.new(*Array(coords))
end
def normalize(point)
[point.x, point.y]
end
end
point = Point.new(3, 4)
transform = PointTransform.new
transform.normalize(point)
#=> [3, 4]
point = transform.denormalize([5, 12])
point.class
#=> Point
point.x
#=> 5
point.y
#=> 12
Transforms can be either mono- or bi-directional.
class UpcaseTransform < Bronze::Transform
# No denormalize method is defined, since this not reversible.
def normalize(string)
string.upcase
end
end
transform = UpcaseTransform
transform.normalize('lower case string')
#=> 'LOWER CASE STRING'
transform.denormalize('UPPER CASE STRING')
#=> raises NotImplementedError
Methods
Each transform must define the #normalize
and #denormalize
methods. Transforms that inherit from Bronze::Transform will raise a NotImplementedError
unless those methods are redefined.
::instance Class Method
Optional. An ::instance
class method is recommended For transforms that take no arguments and have no internal state. ::instance
should memoize a call to ::new
and return the same transform instance each time it is called to minimize object allocation and memory usage.
class CaseTransform
def self.instance
@instance ||= new
end
end
transform = CaseTransform.instance
If the transform does have internal state, e.g. stores values with instance variables, an alternative might be to use a thread-local instance.
#denormalize Method
The #denormalize
method converts the object or data back from an alternate form. This should be the inverse of the #normalize
method (see below).
transform = CaseTransform.new
transform.denormalize('lower case string')
#=> 'LOWER CASE STRING'
For one-way transforms, the #denormalize
method should raise a NotImplementedError
.
#normalize Method
The #normalize
method converts the object or data to an alternate form. By convention, the normalized result is a simpler or standardized form, such as converting an entity to a hash of attributes.
transform = CaseTransform.new
transform.normalize('UPPER CASE STRING')
#=> 'upper case string'
For one-way transforms, use the #normalize
method to transform the data.
Attribute Transforms
Transforms can be used to serialize and store custom attributes (see :transform Option, above). Each attribute transform converts the value to an easily-stored form with #normalize
and restores the original form with #denormalize
.
BigDecimalTransform
require 'bronze/transforms/attributes/big_decimal_transform'
Converts a BigDecimal to a string representation.
transform = Bronze::Transforms::Attributes::BigDecimalTransform.instance
transform.normalize(BigDecimal('3.14'))
#=> '3.14'
decimal = transform.denormalize('3.14')
decimal.class
#=> BigDecimal
decimal.to_f
#=> 3.14
DateTimeTransform
require 'bronze/transforms/attributes/date_time_transform'
Converts a DateTime to a string representation. By default, uses an ISO 8601 string format.
transform = Bronze::Transforms::Attributes::DateTimeTransform.instance
date_time = DateTime.new(1982, 7, 9, 12, 30, 0)
transform.normalize(date)
#=> '1982-07-09T12:30:00+0000'
date_time = transform.denormalize('1982-07-09T12:30:00+0000')
date_time.class
#=> DateTime
date_time.year
#=> 1982
date_time.hour
#=> 12
date_time.zone
#=> '+00:00'
By passing an optional format parameter, the transform can serialize to and from an alternate string format. Not all possible formats are guaranteed to work with both #normalize
and #denormalize
, however, and custom formats may not ensure that all data from the date is stored in the serialized string.
format = '%B %-d, %Y at %T'
transform = Bronze::Transforms::Attributes::DateTimeTransform.new(format)
date_time = DateTime.new(1982, 7, 9, 12, 30, 0)
transform.normalize(date_time)
#=> 'July 9, 1982 at 12:30:00'
date_time = transform.denormalize('July 9, 1982 at 12:30:00')
date_time.class
#=> DateTime
date_time.year
#=> 1982
date_time.hour
#=> 12
date_time.zone
#=> '+00:00'
DateTransform
require 'bronze/transforms/attributes/date_transform'
Converts a Date to a string representation. By default, uses an ISO 8601 string format.
transform = Bronze::Transforms::Attributes::DateTransform.instance
date = Date.new(1982, 7, 9)
transform.normalize(date)
#=> '1982-07-09'
date = transform.denormalize('1982-07-09')
date.class
#=> DateTime
date.year
#=> 1982
date.month
#=> 7
date.day
#=> 9
By passing an optional format parameter, the transform can serialize to and from an alternate string format. Not all possible formats are guaranteed to work with both #normalize
and #denormalize
, however, and custom formats may not ensure that all data from the date is stored in the serialized string.
format = '%B %-d, %Y'
transform = Bronze::Transforms::Attributes::DateTransform.new(format)
date = Date.new(1982, 7, 9)
transform.normalize(date)
#=> 'July 9, 1982'
date = transform.denormalize('July 9, 1982')
date.class
#=> DateTime
date.year
#=> 1982
date.month
#=> 7
date.day
#=> 9
SymbolTransform
require 'bronze/transforms/attributes/symbol_transform'
Converts a Symbol to a string representation.
transform = Bronze::Transforms::Attributes::SymbolTransform.instance
transform.normalize(:symbol_value)
#=> 'symbol_value'
transform.denormalize('string_value')
#=> :string_value
TimeTransform
require 'bronze/transforms/attributes/time_transform'
Converts a Time to an integer representation.
transform = Bronze::Transforms::Attributes::SymbolTransform.instance
time = Time.new(1982, 7, 9)
transform.normalize(time)
#=> 395035200
time = transform.denormalize(395035200)
time.class
#=> Time
time.year
#=> 1982
time.hour
#=> 0
Entity Transforms
The following transforms can be used to serialize or map entity objects.
Normalize Transform
require 'bronze/transforms/entities/normalize_transform'
Converts an entity to a normal representation and vice versa. See Normalization, above.
class Periodical
attribute :title, String
attribute :issue, Integer
attribute :date, DateTime
end
attributes = {
title: 'Triskadecaphobia Today',
issue: 13,
date: DateTime.new(2013, 10, 3, 13, 13, 13, '+13:00')
}
transform =
Bronze::Transforms::Entities::NormalizeTransform.new(Periodical)
periodical = Periodical.new(attributes)
hash = transform.normalize(periodical)
hash.class #=> Hash
hash['title'] #=> 'Triskadecaphobia Today'
hash['issue'] #=> 13
hash['date'] #=> '2013-10-03T13:13:13+1300'
periodical = transform.denormalize(attributes)
#=> an instance of Periodical
periodical.title #=> 'Triskadecaphobia Today'
periodical.issue #=> 13
periodical.date #=> #<DateTime: 2013-10-03T13:13:13+13:00>
The constructor also accepts an array of permitted types.
transform =
Bronze::Transforms::Entities::NormalizeTransform.new(
Periodical,
permit: [DateTime]
)
hash = transform.normalize(periodical)
hash.class #=> Hash
hash['title'] #=> 'Triskadecaphobia Today'
hash['issue'] #=> 13
hash['date'] #=> #<DateTime: 2013-10-03T13:13:13+13:00>