{"id":13880233,"url":"https://github.com/apotonick/disposable","last_synced_at":"2025-05-14T19:07:46.519Z","repository":{"id":11969562,"uuid":"14542256","full_name":"apotonick/disposable","owner":"apotonick","description":"Decorators on top of your ORM layer.","archived":false,"fork":false,"pushed_at":"2024-12-31T15:48:49.000Z","size":652,"stargazers_count":172,"open_issues_count":31,"forks_count":38,"subscribers_count":10,"default_branch":"master","last_synced_at":"2025-04-13T13:58:46.526Z","etag":null,"topics":["objectmapper"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/apotonick.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGES.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2013-11-20T00:31:01.000Z","updated_at":"2025-03-10T15:25:18.000Z","dependencies_parsed_at":"2024-01-13T20:58:11.880Z","dependency_job_id":"afd60eaf-c7bb-430d-a884-903b7e37275e","html_url":"https://github.com/apotonick/disposable","commit_stats":{"total_commits":522,"total_committers":17,"mean_commits":"30.705882352941178","dds":0.08620689655172409,"last_synced_commit":"1fd960db3ca178fdb09000d6f7cbca90bf4ab378"},"previous_names":[],"tags_count":49,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/apotonick%2Fdisposable","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/apotonick%2Fdisposable/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/apotonick%2Fdisposable/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/apotonick%2Fdisposable/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/apotonick","download_url":"https://codeload.github.com/apotonick/disposable/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":254209859,"owners_count":22032897,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["objectmapper"],"created_at":"2024-08-06T08:02:53.076Z","updated_at":"2025-05-14T19:07:45.037Z","avatar_url":"https://github.com/apotonick.png","language":"Ruby","funding_links":[],"categories":["Ruby"],"sub_categories":[],"readme":"# Disposable\n\n_Decorators on top of your ORM layer._\n\n[![Gitter Chat](https://badges.gitter.im/trailblazer/chat.svg)](https://gitter.im/trailblazer/chat)\n[![TRB Newsletter](https://img.shields.io/badge/TRB-newsletter-lightgrey.svg)](http://trailblazer.to/newsletter/)\n[![Build\nStatus](https://travis-ci.org/apotonick/disposable.svg)](https://travis-ci.org/apotonick/disposable)\n[![Gem Version](https://badge.fury.io/rb/disposable.svg)](http://badge.fury.io/rb/disposable)\n\n## Introduction\n\nDisposable is the missing API of ActiveRecord*. The mission:\n\n* Maintain a manipulatable object graph that is a copy/map of a persistent structure.\n* Prevent any write to the persistence layer until you say `sync`.\n* Help designing your domain layer without being restricted to database layouts ([renaming](#renaming), [compositions](#composition), [hash fields](#struct)).\n* Provide additional behavior like [change tracking](#change-tracking), [imperative callbacks](#imperative-callbacks) and [collection semantics](#collection-semantics).\n\n\nDisposable 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.\n\n## API\n\nThe public twin API is unbelievably simple.\n\n1. `Twin::new` creates and populates the twin.\n1. `Twin#\"reader\"` returns the value or nested twin of the property.\n1. `Twin#\"writer\"=(v)` writes the value to the twin, not the model.\n1. `Twin#sync` writes all values to the model.\n1. `Twin#save` writes all values to the model and calls `save` on configured models.\n\n\n## Twin\n\nTwins are only # FIXME % slower than AR alone.\n\nTwins 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.\n\nEvery twin is based on a defined schema.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n  property :title\n  property :playable?, virtual: true # context-sensitive, e.g. current_user dependent.\n\n  collection :songs do\n    property :name\n    property :index\n  end\n\n  property :artist do\n    property :full_name\n  end\nend\n```\n\n## Constructor\n\nTwins get populated from the decorated models.\n\n```ruby\nSong   = Struct.new(:name, :index)\nArtist = Struct.new(:full_name)\nAlbum  = Struct.new(:title, :songs, :artist)\n```\n\nYou need to pass model and the facultative options to the twin constructor.\n\n```ruby\nalbum = Album.new(\"Nice Try\")\ntwin  = AlbumTwin.new(album, playable?: current_user.can?(:play))\n```\n\n## Readers\n\nThis will create a composition object of the actual model and the hash.\n\n```ruby\ntwin.title     #=\u003e \"Nice Try\"\ntwin.playable? #=\u003e true\n```\n\nYou can also override `property` values in the constructor:\n\n```ruby\ntwin = AlbumTwin.new(album, title: \"Plasticash\")\ntwin.title #=\u003e \"Plasticash\"\n```\n\n## Writers\n\nWriters change values on the twin and are _not_ propagated to the model.\n\n```ruby\ntwin.title = \"Skamobile\"\ntwin.title  #=\u003e \"Skamobile\"\nalbum.title #=\u003e \"Nice Try\"\n```\n\nWriters on nested twins will \"twin\" the value.\n\n```ruby\ntwin.songs #=\u003e []\ntwin.songs \u003c\u003c Song.new(\"Adondo\", 1)\ntwin.songs  #=\u003e [\u003cTwin::Song name=\"Adondo\" index=1 model=\u003cSong ..\u003e\u003e]\nalbum.songs #=\u003e []\n```\n\nThe added twin is _not_ passed to the model. Note that the nested song is a twin, not the model itself.\n\n## Sync\n\nGiven the above state change on the twin, here is what happens after calling `#sync`.\n\n```ruby\nalbum.title  #=\u003e \"Nice Try\"\nalbum.songs #=\u003e []\n\ntwin.sync\n\nalbum.title  #=\u003e \"Skamobile\"\nalbum.songs #=\u003e [\u003cSong name=\"Adondo\" index=1\u003e]\n```\n\n`#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.\n\nNote that `sync` might already trigger saving the model as persistence layers like ActiveRecord can't deal with `collection= []` and instantly persist that.\n\nYou may implement your syncing manually by passing a block to `sync`.\n\n```ruby\ntwin.sync do |hash|\n  hash #=\u003e {\n  #  \"title\"     =\u003e \"Skamobile\",\n  #  \"playable?\" =\u003e true,\n  #  \"songs\"     =\u003e [{\"name\"=\u003e\"Adondo\"...}..]\n  # }\nend\n```\n\nInvoking `sync` with block will _not_ write anything to the models.\n\nNeeds to be included explicitly (`Sync`).\n\n## Save\n\nCalling `#save` will do `sync` plus calling `save` on all nested models. This implies that the models need to implement `#save`.\n\n```ruby\ntwin.save\n#=\u003e album.save\n#=\u003e      .songs[0].save\n\n```\n\nNeeds to be included explicitly (`Save`).\n\n## Nested Twin\n\nNested objects can be declared with an inline twin.\n\n```ruby\nproperty :artist do\n  property :full_name\nend\n```\n\nThe setter will automatically \"twin\" the model.\n\n```ruby\ntwin.artist = Artist.new\ntwin.artist #=\u003e \u003cTwin::Artist model=\u003cArtist ..\u003e\u003e\n```\n\nYou can also specify nested objects with an explicit class.\n\n```ruby\nproperty :artist, twin: TwinArtist\n```\n\n## Unnest\n\n# todo: document\n\n## Features\n\nYou can simply `include` feature modules into twins. If you want a feature to be included into all inline twins of your schema, use `::feature`.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n  feature Coercion\n\n  property :artist do\n    # this will now include Coercion, too.\n```\n\n## Coercion\n\nTwins can use [dry-types](https://github.com/dry-rb/dry-types) coercion. This will override the setter in your twin, coerce the incoming value, and call the original setter. _Nothing more_ will happen.\n\nDisposable 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/.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n  feature Coercion\n  feature Setup::SkipSetter\n\n  property :id, type: Types::Params::Integer\n```\n\nThe `: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.\n\n```ruby\ntwin.id = \"1\"\ntwin.id #=\u003e 1\n```\n\nAgain, coercion only happens in the setter.\n\n## Defaults\n\nDefault values can be set via `:default`.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n  feature Default\n\n  property :title, default: \"The Greatest Songs Ever Written\"\n  property :composer, default: Composer.new do\n    property :name, default: -\u003e { \"Object-#{id}\" }\n  end\nend\n```\n\nDefault value is applied when the model's getter returns `nil` when _initializing_ the twin.\n\nNote that `:default` also works with `:virtual` and `readable: false`. `:default` can also be a lambda which is then executed in twin context.\n\n## Collections\n\nCollections can be defined analogue to `property`. The exposed API is the `Array` API.\n\n* `twin.songs = [..]` will override the existing value and \"twin\" every item.\n* `twin.songs \u003c\u003c Song.new` will add and twin.\n* `twin.insert(0, Song.new)` will insert at the specified position and twin.\n\nYou can also delete, replace and move items.\n\n* `twin.songs.delete( twin.songs[0] )`\n\nNone of these operations are propagated to the model.\n\n## Collection Semantics\n\nIn addition to the standard `Array` API the collection adds a handful of additional semantics.\n\n* `songs=`, `songs\u003c\u003c` and `songs.insert` track twin via `#added`.\n* `songs.delete` tracks via `#deleted`.\n* `twin.destroy( twin.songs[0] )` deletes the twin and marks it for destruction in `#to_destroy`.\n* `twin.songs.save` will call `destroy` on all models marked for destruction in `to_destroy`. Tracks destruction via `#destroyed`.\n\nAgain, the model is left alone until you call `sync` or `save`.\n\n## Twin Collections\n\nTo twin a collection of models, you can use `::from_collection`.\n\n```ruby\nSongTwin.from_collection([song, song])\n```\n\nThis will decorate every song instance using a fresh twin.\n\n## Change Tracking\n\nThe `Changed` module will allow tracking of state changes in all properties, even nested structures.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n  feature Changed\n```\n\nNow, consider the following operations.\n\n```ruby\ntwin.name = \"Skamobile\"\ntwin.songs \u003c\u003c Song.new(\"Skate\", 2) # this adds second song.\n```\n\nThis results in the following tracking results.\n\n```ruby\ntwin.changed?             #=\u003e true\ntwin.changed?(:name)      #=\u003e true\ntwin.changed?(:playable?) #=\u003e false\ntwin.songs.changed?       #=\u003e true\ntwin.songs[0].changed?    #=\u003e false\ntwin.songs[1].changed?    #=\u003e true\n```\n\nAssignments from the constructor are _not_ tracked as changes.\n\n```ruby\ntwin = AlbumTwin.new(album)\ntwin.changed? #=\u003e false\n```\n\n## Persistance Tracking\n\nThe `Persisted` module will track the `persisted?` field of the model, implying that your model exposes this field.\n\n```ruby\ntwin.persisted? #=\u003e false\ntwin.save\ntwin.persisted? #=\u003e true\n```\n\nThe `persisted?` field is a copy of the model's persisted? flag.\n\nYou can also use `created?` to find out whether a twin's model was already persisted or just got created in this session.\n\n```ruby\ntwin = AlbumTwin.new(Album.create) # assuming we were using ActiveRecord.\ntwin.created? #=\u003e false\ntwin.save\ntwin.created? #=\u003e false\n```\n\nThis will only return true when the `persisted?` field has flipped.\n\n## Renaming\n\nThe `Expose` module allows renaming properties.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n  feature Expose\n\n  property :song_title, from: :title\n```\n\nThe public accessor is now `song_title` whereas the model's accessor needs to be `title`.\n\n```ruby\nalbum = OpenStruct.new(title: \"Run For Cover\")\nAlbumTwin.new(album).song_title #=\u003e \"Run For Cover\"\n```\n\n## Composition\n\nCompositions of objects can be mapped, too.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n  include Composition\n\n  property :id,    on: :album\n  property :title, on: :album\n  property :songs, on: :cd\n  property :cd_id, on: :cd, from: :id\n```\n\nWhen initializing a composition, you have to pass a hash that contains the composees.\n\n```ruby\nAlbumTwin.new(album: album, cd: CD.find(1))\n```\n\nNote that renaming works here, too.\n\n## Struct\n\nTwins can also map hash properties, e.g. from a deeply nested serialized JSON column.\n\n```ruby\nalbum.permissions #=\u003e {admin: {read: true, write: true}, user: {destroy: false}}\n```\n\nMap that using the `Struct` module.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n  property :permissions do\n     include Struct\n    property :admin do\n      include Struct\n      property :read\n      property :write\n    end\n\n    property :user # you don't have to use Struct everywhere!\n  end\n```\n\nYou get fully object-oriented access to your properties.\n\n```ruby\ntwin.permissions.admin.read #=\u003e true\n```\n\nNote that you do not have to use `Struct` everywhere.\n\n```ruby\ntwin.permissions.user #=\u003e {destroy: false}\n```\n\nOf course, this works for writing, too.\n\n```ruby\ntwin.permissions.admin.read = :MAYBE\n```\n\nAfter `sync`ing, you will find a hash in the model.\n\n```ruby\nalbum.permissions #=\u003e {admin: {read: :MAYBE, write: true}, user: {destroy: false}}\n```\n\n## With Representers\n\nthey indirect data, the twin's attributes get assigned without writing to the persistence layer, yet.\n\n## With Contracts\n\n## Overriding Getter for Presentation\n\nYou can override getters for presentation.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n    property :title\n\n    def title\n      super.upcase\n    end\n  end\n```\n\nBe careful, though. The getter normally is also called in `sync` when writing properties to the models.\n\nYou can skip invocation of getters in `sync` and read values from `@fields` directly by including `Sync::SkipGetter`.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n  feature Sync\n  feature Sync::SkipGetter\n```\n\n## Manual Coercion\n\nYou can override setters for manual coercion.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n    property :title\n\n    def title=(v)\n      super(v.trim)\n    end\n  end\n```\n\nBe careful, though. The setter normally is also called in `setup` when copying properties from the models to the twin.\n\nAnalogue to `SkipGetter`, include `Setup::SkipSetter` to write values directly to `@fields`.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n  feature Setup::SkipSetter\n```\n\n\n## Imperative Callbacks\n\nPlease refer to the [full documentation](http://trailblazer.to/gems/disposable/callback.html).\n\nNote: [Chapter 8 of the Trailblazer](http://leanpub.com/trailblazer) book is dedicated to callbacks and discusses them in great detail.\n\nCallbacks use the fact that twins track state changes. This allows to execute callbacks on certain conditions.\n\n```ruby\nCallback.new(twin).on_create { |twin| .. }\nCallback.new(twin.songs).on_add { |twin| .. }\nCallback.new(twin.songs).on_add { |twin| .. }\n```\n\nIt works as follows.\n\n1. Twins track state changes, like _\"item added to collection (`on_add`)\"_ or _\"property changed (`on_change`)\"_.\n2. You decide when to invoke one or a group of callbacks. This is why there's no `before_save` and the like anymore.\n3. You also decide _what_ events to consider by calling the respective events only, like `on_add`.\n4. The `Callback` will now find out which properties of the twin are affected and exectue your passed code for each of them.\n\nThis is called _Imperative Callback_ and the opposite of what you've learned from Rails.\n\nBy inversing the control, we don't need `before_` or `after_`. This is in your hands now and depends on where you invoke your callbacks.\n\n## Events\n\nThe following events are available in `Callback`.\n\nDon'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).\n\n* `on_update`: Invoked when the underlying model was persisted, yet, at twin initialization and attributes have changed since then.\n* `on_add`: For every twin that has been added to a collection.\n* `on_add(:create)`: For every twin that has been added to a collection and got persisted. This will only pick up collection items after `sync` or `save`.\n\n* `on_delete`: For every item that has been deleted from a collection.\n* `on_destroy`: For every item that has been removed from a collection and physically destroyed.\n\n* `on_change`: For every item that has changed attributes. When `persisted?` has flippend, this will be triggered, too.\n* `on_change(:email)`: When the scalar field changed.\n\n\n## Callback Groups\n\n`Callback::Group` simplifies grouping callbacks and allows nesting.\n\n```ruby\nclass AfterSave \u003c Disposable::Callback::Group\n  on_change :expire_cache!\n\n  collection :songs do\n    on_add :notify_album!\n    on_add :reset_song!\n  end\n\n  on_update :rehash_name!, property: :title\n\n  property :artist do\n    on_change :sing!\n  end\nend\n```\n\nCalling that group on a twin will invoke all callbacks that apply, in the order they were added.\n\n```ruby\nAfterSave.new(twin).(context: self)\n```\n\nMethods 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`.\n\nAn options hash is passed as the second argument. # TODO: document Group.(operation: Object.new).\n\nAgain, 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.\n\n## Callback Inheritance\n\nYou can inherit groups, add and remove callbacks.\n\n```ruby\nclass EnhancedAfterSave \u003c AfterSave\n  on_change :redo!\n\n  collection :songs do\n    on_add :rewind!\n  end\n\n  remove! :on_change, :expire_cache!\nend\n```\n\nThe callbacks will be _appended_ to the existing chain.\n\nInstead of appending, you may also refine existing callbacks.\n\n```ruby\nclass EnhancedAfterSave \u003c AfterSave\n  collection :songs, inherit: true do\n    on_delete :rewind!\n  end\nend\n```\n\nThis will add the `rewind!` callback to the `songs` property, resulting in the following chain.\n\n```ruby\ncollection :songs do\n  on_add    :notify_album!\n  on_add    :reset_song!\n  on_delete :rewind!\nend\n```\n\n## Readable, Writeable, Virtual\n\nProperties can have various access settings.\n\n* `readable: false` won't read from the model in `Setup`.\n* `writeable: false` won't write to model in `Sync`.\n* `virtual: true` is both settings above combined.\n\n## Options\n\nTo inject context data into a twin that is not part of any model, you can simply use `:virtual` properties.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n  property :title\n  property :current_user, virtual: true\nend\n```\n\nYou can now pass the `current_user` as an option into the constructor and then access it via the reader.\n\n```ruby\ntwin = AlbumTwin.new(album, current_user: User.find(1))\ntwin.current_user #=\u003e \u003cUser id:1\u003e\n```\n\n## Parent\n\nBy using the `Parent` feature you can access the parent twin of a nested one.\n\n```ruby\nclass AlbumTwin \u003c Disposable::Twin\n  feature Parent\n\n  property :artist do\n    property :name\n  end\nend\n```\n\nUse `parent` to grab the nested's container twin.\n\n```ruby\ntwin = AlbumTwin.new(Album.new(artist: Artist.new))\n\ntwin.artist.parent #=\u003e twin\n```\n\nNote that this will internally add a `parent` property.\n\n## Builders\n\n## Used In\n\n* [Reform](https://github.com/apotonick/reform) forms are based on twins and add a little bit of form decoration on top. Every nested form is a twin.\n* [Trailblazer](https://github.com/apotonick/trailblazer) uses twins as decorators and callbacks in operations to structure business logic.\n\n## Development\n\n* `rake test` runs all tests without `builder_test.rb`. For the latter, run `BUNDLE_GEMFILE=Gemfile_builder_test.rb bundle exec rake test_builder`\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fapotonick%2Fdisposable","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fapotonick%2Fdisposable","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fapotonick%2Fdisposable/lists"}