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 syncing, only the nested structure will be considered.

twin.sync
album.artist.email #=> "duran@duran.com"

Public API

The public twin API is unbelievably simple.

  1. Twin::new creates and populates the twin.
  2. Twin#"reader" returns the value or nested twin of the property.
  3. Twin#"writer"=(v) writes the value to the twin, not the model.
  4. Twin#sync writes all values to the model. Needs to be enabled explicity with feature Sync.
  5. Twin#save writes all values to the model, then calls save on configured models. Needs to be enabled explicity with feature 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<< and songs.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 call destroy on all models marked for destruction in to_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.

This module is not limited to Postgres' JSONB and hstore column type, but may also interact with JSON or serialized-hash columns.

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 syncing, 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