Disposable
- Last updated 22 Aug 22
Disposable provides twins.
Twin API
A twin is an intermediate object that usually sits between persistence layer and your application code. It’s a domain object that helps you model your application domain. Sometimes this is called decorator.
Often, a twin maps directly to one persistent “model”. However, twins are absolutely not limited to your database layout - the opposite is the case. One twin can be a composition of many underlying models, with renaming, delegating, and more mapping features, that allow you modeling objects for your application, and not objects dictated by your database layout.
Declarative API
Every twin is based on a schema which comes in form of a Disposable::Twin
class.
class AlbumTwin < Disposable::Twin
property :title
collection :songs do
property :name
property :index
end
property :artist do
property :full_name
end
end
The self-explaining DSL known from many Trailblazer gems allows to define flat or nested twins.
Unnest
To expose a nested property on an outer level, use ::unnest
.
class AlbumTwin < Disposable::Twin
property :artist do
property :email
end
unnest :email, from: :artist
end
The email
accessors will now be on top-level, hiding the nested structure to the outside world.
album = Album.find(1)
twin = AlbumTwin.new(album)
twin.email #=> "duran@duran.to"
twin.email = "duran@duran.com"
When sync
ing, only the nested structure will be considered.
twin.sync
album.artist.email #=> "duran@duran.com"
Public API
The public twin API is unbelievably simple.
Twin::new
creates and populates the twin.Twin#"reader"
returns the value or nested twin of the property.Twin#"writer"=(v)
writes the value to the twin, not the model.Twin#sync
writes all values to the model. Needs to be enabled explicity withfeature Sync
.Twin#save
writes all values to the model, then callssave
on configured models. Needs to be enabled explicity withfeature Save
.
Constructor
Twins get populated from the decorated models.
Song = Struct.new(:name, :index)
Artist = Struct.new(:full_name)
Album = Struct.new(:title, :songs, :artist)
You need to pass model and the facultative options to the twin constructor.
album = Album.new("Nice Try")
twin = AlbumTwin.new(album, playable?: current_user.can?(:play))
Readers
This will create a composition object of the actual model and the hash.
twin.title #=> "Nice Try"
twin.playable? #=> true
You can also override property
values in the constructor:
twin = AlbumTwin.new(album, title: "Plasticash")
twin.title #=> "Plasticash"
Writers
Writers change values on the twin and are not propagated to the model.
twin.title = "Skamobile"
twin.title #=> "Skamobile"
album.title #=> "Nice Try"
Writers on nested twins will “twin” the value.
twin.songs #=> []
twin.songs << Song.new("Adondo", 1)
twin.songs #=> [<Twin::Song name="Adondo" index=1 model=<Song ..>>]
album.songs #=> []
The added twin is not passed to the model. Note that the nested song is a twin, not the model itself.
Sync
To write twin data back to the models, use sync
. You have to include Sync
.
class AlbumTwin < Disposable::Twin
feature Sync
#..
end
Given the above state change on the twin, here is what happens when calling #sync
.
album.title #=> "Nice Try"
album.songs #=> []
twin.sync
album.title #=> "Skamobile"
album.songs #=> [<Song name="Adondo" index=1>]
#sync
writes all configured attributes back to the models using public setters such as album.name=
or album.songs=
. This works recursively and will sync the entire object graph.
Note that sync
might unexpectedly trigger saving of the model. Some persistence layers, for instance ActiveRecord, can't deal with collection= []
and instantly persist that.
Sync with Block
You may implement your syncing manually by passing a block to sync
. The block argument is a nested hash of property/value pairs.
twin.sync do |hash|
hash #=> {
# "title" => "Skamobile",
# "playable?" => true,
# "songs" => [{ "name"=>"Adondo", ..}],
# "composer" => nil
# }
end
Invoking sync
with a block will not write anything to the models. Note that nil
values are included into the hash, too (0.4.0).
Save
Calling #save
will do sync
, then call save
on all nested models. This implies that the models need to implement #save
. save
is unavailable by default; you have to explicitly include it with feature Save
:
class AlbumTwin < DispoableTwin
feature Sync
feature Save
property :title
collection :songs do
property :name
property :index
end
end
album = Album.find(1)
twin = AlbumTwin.new(album)
twin.save
#=> syncs data back to the models, then calls:
#=> album.save
#=> album.songs.each { |song| song.save }
Nested Twin
Nested objects can be declared with an inline twin.
property :artist do
property :full_name
end
The setter will automatically “twin” the model.
twin.artist = Artist.new
twin.artist #=> <Twin::Artist model=<Artist ..>>
You can also specify nested objects with an explicit class.
property :artist, twin: TwinArtist
Features
You can simply include
feature modules into twins. If you want a feature to be included into all inline twins of your schema, use ::feature
.
class AlbumTwin < Disposable::Twin
feature Coercion
property :artist do
# this will now include Coercion, too.
Coercion
Twins can use dry-types coercion. This will override the setter in your twin, coerce the incoming value, and call the original setter. Nothing more will happen.
Disposable already defines a module Disposable::Twin::Coercion::Types
with all the Dry::Types built-in types. So you can use any of the documented types.
class AlbumTwin < Disposable::Twin
feature Coercion
property :id, type: Types::Form::Int
The :type
option defines the coercion type. You may include Setup::SkipSetter
, too, as otherwise the coercion will happen at initialization time and in the setter.
twin.id = "1"
twin.id #=> 1
Again, coercion only happens in the setter.
Nilify
Coercion also supports the conversion of blank strings (""
) into nil
. This is known as nilify and provided via the :nilify
option.
property :id, type: Types::Form::Int, nilify: true
This will result in the following behavior.
twin.id = ""
twin.id #=> nil
Note that you can use :nilify
without specifying a :type
.
Nilify has been deprecated for dry-v >= 1.x
Defaults
Default values can be set via :default
.
class AlbumTwin < Disposable::Twin
feature Default
property :title, default: "The Greatest Songs Ever Written"
property :composer, default: Composer.new do
property :name, default: -> { "Object-#{id}" }
end
end
Default value is applied when the model’s getter returns nil
when initializing the twin.
Note that :default
also works with :virtual
and readable: false
. :default
can also be a lambda which is then executed in twin context.
Collections
Collections can be defined analogue to property
. The exposed API is the Array
API.
twin.songs = [..]
will override the existing value and “twin” every item.twin.songs << Song.new
will add and twin.twin.insert(0, Song.new)
will insert at the specified position and twin.
You can also delete, replace and move items.
twin.songs.delete( twin.songs[0] )
None of these operations are propagated to the model.
Collection Semantics
In addition to the standard Array
API the collection adds a handful of additional semantics.
songs=
,songs<<
andsongs.insert
track twin via#added
.songs.delete
tracks via#deleted
.twin.destroy( twin.songs[0] )
deletes the twin and marks it for destruction in#to_destroy
.twin.songs.save
will calldestroy
on all models marked for destruction into_destroy
. Tracks destruction via#destroyed
.
Again, the model is left alone until you call sync
or save
.
Property::Hash
0.3.2
The Property::Hash
module allows working with generic hash fields with any level of nesting. Instead of clumsy hash operations, you have Ruby objects.
A serialized hash field must return a Ruby hash.
album = Album.find(1)
album.payload #=>
{
"title"=> "A View To A Kill",
"band" => {
"name" => "Duran Duran"
}
}
Here, the payload
field is such a serialized hash field.
Letting the twin handle the hash field works via the :field
option.
require "disposable/twin/property/hash"
class Album::Twin < Disposable::Twin
feature Sync
include Property::Hash
property :id # or whatever you need.
property :payload, field: :hash do
property :title
property :band do
property :name
end
end
end
Note that you can have any level of nesting, and are free to use collection
.
You get fully object-oriented access to your properties.
twin = Album::Twin.new(album)
twin.payload.band.name #=> "Duran Duran"
This works for writing, too.
twin.payload.band.name = "James Bond"
After sync
ing, the model’s hash field will be updated.
album.payload #=>
{
"title"=> "A View To A Kill",
"band" => {
"name" => "James Bond"
}
}
If you don’t know the field names, you can define a scalar property
.
class Album::Twin < Disposable::Twin
feature Sync
include Property::Hash
property :id # or whatever you need.
property :payload, field: :hash do
property :title
property :band # scalar!
end
end
This will return the native hash.
twin.payload.band #=> { "band" => { "name" => "Duran Duran" } }
The Property::Hash
module also works great with ::unnest and is a fantastic way to get rid of migrations and data that doesn’t need a dedicated column.
Twin Collections
To twin a collection of models, you can use ::from_collection
.
SongTwin.from_collection([song, song])
This will decorate every song instance using a fresh twin.
Renaming
The Expose
module allows renaming properties.
class AlbumTwin < Disposable::Twin
feature Expose
property :song_title, from: :title
The public accessor is now song_title
whereas the model’s accessor needs to be title
.
album = OpenStruct.new(title: "Run For Cover")
AlbumTwin.new(album).song_title #=> "Run For Cover"
Composition
Compositions of objects can be mapped, too.
class AlbumTwin < Disposable::Twin
include Composition
property :id, on: :album
property :title, on: :album
property :songs, on: :cd
property :cd_id, on: :cd, from: :id
When initializing a composition, you have to pass a hash that contains the composees.
AlbumTwin.new(album: album, cd: CD.find(1))
Note that renaming works here, too.
Overriding Getter for Presentation
You can override getters for presentation.
class AlbumTwin < Disposable::Twin
property :title
def title
super.upcase
end
end
Be careful, though. The getter normally is also called in sync
when writing properties to the models.
You can skip invocation of getters in sync
and read values from @fields
directly by including Sync::SkipGetter
.
class AlbumTwin < Disposable::Twin
feature Sync
feature Sync::SkipGetter
Manual Coercion
You can override setters for manual coercion.
class AlbumTwin < Disposable::Twin
property :title
def title=(v)
super(v.trim)
end
end
Be careful, though. The setter normally is also called in setup
when copying properties from the models to the twin.
Analogue to SkipGetter
, include Setup::SkipSetter
to write values directly to @fields
.
class AlbumTwin < Disposable::Twin
feature Setup::SkipSetter
Change Tracking
The Changed
module will allow tracking of state changes in all properties, even nested structures.
class AlbumTwin < Disposable::Twin
feature Changed
Now, consider the following operations.
twin.name = "Skamobile"
twin.songs << Song.new("Skate", 2) # this adds second song.
This results in the following tracking results.
twin.changed? #=> true
twin.changed?(:name) #=> true
twin.changed?(:playable?) #=> false
twin.songs.changed? #=> true
twin.songs[0].changed? #=> false
twin.songs[1].changed? #=> true
Assignments from the constructor are not tracked as changes.
twin = AlbumTwin.new(album)
twin.changed? #=> false
When used with Coercion
, note that first coercion happens, then the assignment, then the tracking logic.
That will lead to the following assignment not being marked as change.
twin.released #=> true
twin.released = 1
twin.released #=> true
twin.changed?(:released) #=> false
Twin: Default
Twins allow default values to be set on the twin. This is provided by the Default
module.
Default values can be set via :default
.
class AlbumTwin < Disposable::Twin
feature Default
property :title, default: "The Greatest Songs Ever Written"
property :composer, default: Composer.new do
property :name
end
end
The default value is applied when the model’s getter returns nil
in Setup
.
Note that this also works for nested properties.
Struct Defaults
Defaults also work for Struct
.
class AlbumTwin < Disposable::Twin
feature Default
property :settings, default: Hash.new do
include Struct
property :enabled, default: "yes"
property :roles, default: Hash.new do
include Struct
property :admin, default: "maybe"
end
end
end
You can now access nested hashes even if they were not present initially.
album = Album.new
album.settings #=> nil
twin = AlbumTwin.new(album)
twin.settings.enabled #=> "yes"
twin.settings.roles.admin #=> "maybe"
Naturally, this will then write a valid hash back in sync
.
twin.sync
album.settings #=> {"enabled"=>"yes", "roles"=>{"admin"=>"maybe"}}
Disposable: Callback
Callbacks in Disposable implement the Imperative Callback pattern.
Declarative API
A Callback::Group
allows defining nested callbacks.
class Group < Disposable::Callback::Group
on_change :change!
collection :songs do
on_add :notify_album!
end
# on_change :rehash_name!, property: :title
end
Per default, callback methods will be invoked on the respective group. In nested setups, this means you also need to nest the method implementation.
class Group < Disposable::Callback::Group
on_change :change!
def change!(twin, options)
options[:string] << "change!"
end
collection :songs do
on_add :notify_album!
def notify_album!(twin, options)
options[:string] << "notify_album!"
end
end
end
Invocation
A group is invoked using call
.
Group.new(twin).()
Context
You can use an arbitrary context object to implement the callback methods.
class CallbackImplementation
def change!(twin, options)
options[:string] << "change!"
end
def notify_album!(twin, options)
options[:string] << "notify_album!"
end
end
When invoking, specify it using the :context
option.
Group.new(twin).(context: CallbackImplementation.new)
This allows leaving the Group
free of implementation, as a pure definition of events, only. Also, note that you don’t have any nesting with a context object, all methods are implemented on the same level in a simple object.
Options
You may pass arbitrary options to all callback methods.
Group.new(twin).(context: CallbackImplementation.new, string: "")
This allows injecting dependencies without global state.
class CallbackImplementation
def change!(twin, options)
options[:string] << "change!"
end