Disposable
Decorators on top of your ORM layer.
Introduction
Disposable is the missing API of ActiveRecord*. The mission:
- Maintain a manipulatable object graph that is a copy/map of a persistent structure.
- Prevent any write to the persistence layer until you say
sync
. - Help designing your domain layer without being restricted to database layouts (renaming, compositions, hash fields).
- Provide additional behavior like change tracking, imperative callbacks and collection semantics.
Disposable gives you "Twins": non-persistent domain objects. That is reflected in the name of the gem. They can read from and write values to a persistent object and abstract the persistence layer until data is synced to the model.
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. -
Twin#save
writes all values to the model and callssave
on configured models.
Twin
Twins are only # FIXME % slower than AR alone.
Twins implement light-weight decorators objects with a unified interface. They map objects, hashes, and compositions of objects, along with optional hashes to inject additional options.
Every twin is based on a defined schema.
class AlbumTwin < Disposable::Twin
property :title
property :playable?, virtual: true # context-sensitive, e.g. current_user dependent.
collection :songs do
property :name
property :index
end
property :artist do
property :full_name
end
end
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
Given the above state change on the twin, here is what happens after 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 as album.name=
or album.songs=
. This is recursive and will sync the entire object graph.
Note that sync
might already trigger saving the model as persistence layers like ActiveRecord can't deal with collection= []
and instantly persist that.
You may implement your syncing manually by passing a block to sync
.
twin.sync do |hash|
hash #=> {
# "title" => "Skamobile",
# "playable?" => true,
# "songs" => [{"name"=>"Adondo"...}..]
# }
end
Invoking sync
with block will not write anything to the models.
Needs to be included explicitly (Sync
).
Save
Calling #save
will do sync
plus calling save
on all nested models. This implies that the models need to implement #save
.
twin.save
#=> album.save
#=> .songs[0].save
Needs to be included explicitly (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
Unnest
todo: document
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 types documented in http://dry-rb.org/gems/dry-types/built-in-types/.
class AlbumTwin < Disposable::Twin
feature Coercion
feature Setup::SkipSetter
property :id, type: Types::Params::Integer
The :type
option defines the coercion type. You may incluce 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.
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
.
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.
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
Persistance Tracking
The Persisted
module will track the persisted?
field of the model, implying that your model exposes this field.
twin.persisted? #=> false
twin.save
twin.persisted? #=> true
The persisted?
field is a copy of the model's persisted? flag.
You can also use created?
to find out whether a twin's model was already persisted or just got created in this session.
twin = AlbumTwin.new(Album.create) # assuming we were using ActiveRecord.
twin.created? #=> false
twin.save
twin.created? #=> false
This will only return true when the persisted?
field has flipped.
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.
Struct
Twins can also map hash properties, e.g. from a deeply nested serialized JSON column.
album.permissions #=> {admin: {read: true, write: true}, user: {destroy: false}}
Map that using the Struct
module.
class AlbumTwin < Disposable::Twin
property :permissions do
include Struct
property :admin do
include Struct
property :read
property :write
end
property :user # you don't have to use Struct everywhere!
end
You get fully object-oriented access to your properties.
twin.permissions.admin.read #=> true
Note that you do not have to use Struct
everywhere.
twin.permissions.user #=> {destroy: false}
Of course, this works for writing, too.
twin.permissions.admin.read = :MAYBE
After sync
ing, you will find a hash in the model.
album.permissions #=> {admin: {read: :MAYBE, write: true}, user: {destroy: false}}
With Representers
they indirect data, the twin's attributes get assigned without writing to the persistence layer, yet.
With Contracts
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
Imperative Callbacks
Please refer to the full documentation.
Note: Chapter 8 of the Trailblazer book is dedicated to callbacks and discusses them in great detail.
Callbacks use the fact that twins track state changes. This allows to execute callbacks on certain conditions.
Callback.new(twin).on_create { |twin| .. }
Callback.new(twin.songs).on_add { |twin| .. }
Callback.new(twin.songs).on_add { |twin| .. }
It works as follows.
- Twins track state changes, like "item added to collection (
on_add
)" or "property changed (on_change
)". - You decide when to invoke one or a group of callbacks. This is why there's no
before_save
and the like anymore. - You also decide what events to consider by calling the respective events only, like
on_add
. - The
Callback
will now find out which properties of the twin are affected and exectue your passed code for each of them.
This is called Imperative Callback and the opposite of what you've learned from Rails.
By inversing the control, we don't need before_
or after_
. This is in your hands now and depends on where you invoke your callbacks.
Events
The following events are available in Callback
.
Don't confuse that with event triggering, though! Callbacks are passive, calling an event method means the callback will look for twins that have tracked the respective event (e.g. an twin has change
d).
-
on_update
: Invoked when the underlying model was persisted, yet, at twin initialization and attributes have changed since then. -
on_add
: For every twin that has been added to a collection. -
on_add(:create)
: For every twin that has been added to a collection and got persisted. This will only pick up collection items aftersync
orsave
. -
on_delete
: For every item that has been deleted from a collection. -
on_destroy
: For every item that has been removed from a collection and physically destroyed. -
on_change
: For every item that has changed attributes. Whenpersisted?
has flippend, this will be triggered, too. -
on_change(:email)
: When the scalar field changed.
Callback Groups
Callback::Group
simplifies grouping callbacks and allows nesting.
class AfterSave < Disposable::Callback::Group
on_change :expire_cache!
collection :songs do
on_add :notify_album!
on_add :reset_song!
end
on_update :rehash_name!, property: :title
property :artist do
on_change :sing!
end
end
Calling that group on a twin will invoke all callbacks that apply, in the order they were added.
AfterSave.new(twin).(context: self)
Methods like :sing!
will be invoked on the :context
object. Likewise, nested properties will be retrieved by simply calling the getter on the twin, like twin.songs
.
An options hash is passed as the second argument. # TODO: document Group.(operation: Object.new).
Again, only the events that match will be invoked. If the top level twin hasn't changed, expire_cache!
won't be invoked. This works by simply using Callback
under the hood.
Callback Inheritance
You can inherit groups, add and remove callbacks.
class EnhancedAfterSave < AfterSave
on_change :redo!
collection :songs do
on_add :rewind!
end
remove! :on_change, :expire_cache!
end
The callbacks will be appended to the existing chain.
Instead of appending, you may also refine existing callbacks.
class EnhancedAfterSave < AfterSave
collection :songs, inherit: true do
on_delete :rewind!
end
end
This will add the rewind!
callback to the songs
property, resulting in the following chain.
collection :songs do
on_add :notify_album!
on_add :reset_song!
on_delete :rewind!
end
Readable, Writeable, Virtual
Properties can have various access settings.
-
readable: false
won't read from the model inSetup
. -
writeable: false
won't write to model inSync
. -
virtual: true
is both settings above combined.
Options
To inject context data into a twin that is not part of any model, you can simply use :virtual
properties.
class AlbumTwin < Disposable::Twin
property :title
property :current_user, virtual: true
end
You can now pass the current_user
as an option into the constructor and then access it via the reader.
twin = AlbumTwin.new(album, current_user: User.find(1))
twin.current_user #=> <User id:1>
Parent
By using the Parent
feature you can access the parent twin of a nested one.
class AlbumTwin < Disposable::Twin
feature Parent
property :artist do
property :name
end
end
Use parent
to grab the nested's container twin.
twin = AlbumTwin.new(Album.new(artist: Artist.new))
twin.artist.parent #=> twin
Note that this will internally add a parent
property.
Builders
Used In
- Reform forms are based on twins and add a little bit of form decoration on top. Every nested form is a twin.
- Trailblazer uses twins as decorators and callbacks in operations to structure business logic.
Development
-
rake test
runs all tests withoutbuilder_test.rb
. For the latter, runBUNDLE_GEMFILE=Gemfile_builder_test.rb bundle exec rake test_builder