disposable 0.0.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +2 -5
  3. data/CHANGES.md +4 -0
  4. data/Gemfile +1 -1
  5. data/README.md +154 -1
  6. data/database.sqlite3 +0 -0
  7. data/disposable.gemspec +7 -7
  8. data/gemfiles/Gemfile.rails-3.0.lock +10 -8
  9. data/gemfiles/Gemfile.rails-3.2.lock +9 -7
  10. data/gemfiles/Gemfile.rails-4.0.lock +9 -7
  11. data/gemfiles/Gemfile.rails-4.1.lock +10 -8
  12. data/lib/disposable.rb +6 -7
  13. data/lib/disposable/callback.rb +174 -0
  14. data/lib/disposable/composition.rb +21 -58
  15. data/lib/disposable/expose.rb +49 -0
  16. data/lib/disposable/twin.rb +85 -38
  17. data/lib/disposable/twin/builder.rb +12 -30
  18. data/lib/disposable/twin/changed.rb +50 -0
  19. data/lib/disposable/twin/collection.rb +95 -0
  20. data/lib/disposable/twin/composition.rb +43 -15
  21. data/lib/disposable/twin/option.rb +1 -1
  22. data/lib/disposable/twin/persisted.rb +20 -0
  23. data/lib/disposable/twin/property_processor.rb +29 -0
  24. data/lib/disposable/twin/representer.rb +42 -14
  25. data/lib/disposable/twin/save.rb +19 -34
  26. data/lib/disposable/twin/schema.rb +31 -0
  27. data/lib/disposable/twin/setup.rb +38 -0
  28. data/lib/disposable/twin/sync.rb +114 -0
  29. data/lib/disposable/version.rb +1 -1
  30. data/test/api_semantics_test.rb +263 -0
  31. data/test/callback_group_test.rb +222 -0
  32. data/test/callbacks_test.rb +450 -0
  33. data/test/example.rb +40 -0
  34. data/test/expose_test.rb +92 -0
  35. data/test/persisted_test.rb +101 -0
  36. data/test/test_helper.rb +64 -0
  37. data/test/twin/benchmarking.rb +33 -0
  38. data/test/twin/builder_test.rb +32 -0
  39. data/test/twin/changed_test.rb +108 -0
  40. data/test/twin/collection_test.rb +223 -0
  41. data/test/twin/composition_test.rb +56 -25
  42. data/test/twin/expose_test.rb +73 -0
  43. data/test/twin/feature_test.rb +61 -0
  44. data/test/twin/from_test.rb +37 -0
  45. data/test/twin/inherit_test.rb +57 -0
  46. data/test/twin/option_test.rb +27 -0
  47. data/test/twin/readable_test.rb +57 -0
  48. data/test/twin/save_test.rb +192 -0
  49. data/test/twin/schema_test.rb +69 -0
  50. data/test/twin/setup_test.rb +139 -0
  51. data/test/twin/skip_unchanged_test.rb +64 -0
  52. data/test/twin/struct_test.rb +168 -0
  53. data/test/twin/sync_option_test.rb +228 -0
  54. data/test/twin/sync_test.rb +128 -0
  55. data/test/twin/twin_test.rb +49 -128
  56. data/test/twin/writeable_test.rb +56 -0
  57. metadata +106 -20
  58. data/STUFF +0 -4
  59. data/lib/disposable/twin/finders.rb +0 -29
  60. data/lib/disposable/twin/new.rb +0 -30
  61. data/lib/disposable/twin/save_.rb +0 -21
  62. data/test/composition_test.rb +0 -102
@@ -0,0 +1,31 @@
1
+ # TODO: this needs tests and should probably go to Representable. we can move tests from Reform for that.
2
+ class Disposable::Twin::Schema
3
+ def self.from(source_class, options) # TODO: can we re-use this for all the decorator logic in #validate, etc?
4
+ representer = Class.new(options[:superclass])
5
+ representer.send :include, *options[:include]
6
+
7
+ source_representer = options[:representer_from].call(source_class)
8
+
9
+ source_representer.representable_attrs.each do |dfn|
10
+ local_options = dfn[options[:options_from]] || {} # e.g. deserializer: {..}.
11
+ new_options = dfn.instance_variable_get(:@options).merge(local_options)
12
+
13
+ from_scalar!(options, dfn, new_options, representer) && next unless dfn[:extend]
14
+ from_inline!(options, dfn, new_options, representer)
15
+ end
16
+
17
+ representer
18
+ end
19
+
20
+ private
21
+ def self.from_scalar!(options, dfn, new_options, representer)
22
+ representer.property(dfn.name, new_options)
23
+ end
24
+
25
+ def self.from_inline!(options, dfn, new_options, representer)
26
+ nested = dfn[:extend].evaluate(nil) # nested now can be a Decorator, a representer module, a Form, a Twin.
27
+ dfn_options = new_options.merge(extend: from(nested, options))
28
+
29
+ representer.property(dfn.name, dfn_options)
30
+ end
31
+ end
@@ -0,0 +1,38 @@
1
+ module Disposable
2
+ class Twin
3
+ # Read all properties at twin initialization time from model.
4
+ # Simply pass through all properties from the model to the respective twin writer method.
5
+ # This will result in all twin properties/collection items being twinned, and collections
6
+ # being Collection to expose the desired public API.
7
+ module Setup
8
+ # test is in incoming hash? is nil on incoming model?
9
+
10
+ def initialize(model, options={})
11
+ @fields = {}
12
+ @model = model
13
+ @mapper = mapper_for(model) # mapper for model.
14
+
15
+ setup_properties!(model, options)
16
+ end
17
+
18
+ private
19
+ def mapper_for(model)
20
+ model
21
+ end
22
+
23
+ def setup_properties!(model, options)
24
+ schema.each do |dfn|
25
+ next if dfn[:readable] == false
26
+
27
+ name = dfn.name
28
+ value = options[name.to_sym] || mapper.send(name) # model.title.
29
+
30
+ send(dfn.setter, value)
31
+ end
32
+
33
+ @fields.merge!(options) # FIXME: hash/string. # FIXME: call writer!!!!!!!!!!
34
+ # from_hash(options) # assigns known properties from options.
35
+ end
36
+ end # Setup
37
+ end
38
+ end
@@ -0,0 +1,114 @@
1
+ # #sync!
2
+ # 1. assign scalars to model (respecting virtual, excluded attributes)
3
+ # 2. call sync! on nested
4
+ #
5
+ # Note: #sync currently implicitly saves AR objects with collections
6
+ class Disposable::Twin
7
+ module Sync
8
+ def sync_models(options={})
9
+ return yield to_nested_hash if block_given?
10
+
11
+ sync!(options)
12
+ end
13
+ alias_method :sync, :sync_models
14
+
15
+ # reading from fields allows using readers in form for presentation
16
+ # and writers still pass to fields in #validate????
17
+
18
+ # Sync all scalar attributes, call sync! on nested and return model.
19
+ def sync!(options) # semi-public.
20
+ options_for_sync = sync_options(Decorator::Options[options])
21
+
22
+ schema.each(options_for_sync) do |dfn|
23
+ unless dfn[:twin]
24
+ mapper.send(dfn.setter, send(dfn.getter)) # always sync the property
25
+ next
26
+ end
27
+
28
+ nested_model = PropertyProcessor.new(dfn, self).() { |twin| twin.sync!({}) }
29
+
30
+ next if nested_model.nil?
31
+
32
+ mapper.send(dfn.setter, nested_model) # @model.artist = <Artist>
33
+ end
34
+
35
+ model
36
+ end
37
+
38
+ def self.included(includer)
39
+ includer.extend ToNestedHash::ClassMethods
40
+ end
41
+
42
+ private
43
+
44
+ module ToNestedHash
45
+ def to_nested_hash(*)
46
+ self.class.nested_hash_representer.new(self).to_hash
47
+ end
48
+
49
+ module ClassMethods
50
+ # Create a hash representer on-the-fly to serialize the form to a hash.
51
+ def nested_hash_representer
52
+ @nested_hash_representer ||= Class.new(representer_class) do
53
+ include Representable::Hash
54
+
55
+ representable_attrs.each do |dfn|
56
+ dfn.merge!(readable: true) # the nested hash contains all fields.
57
+ dfn.merge!(as: dfn[:private_name]) # nested hash keys by model property names.
58
+
59
+ dfn.merge!(
60
+ prepare: lambda { |model, *| model }, # TODO: why do we need that here?
61
+ serialize: lambda { |form, args| form.to_nested_hash },
62
+ ) if dfn[:twin]
63
+
64
+ self
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ include ToNestedHash
71
+
72
+
73
+ module SyncOptions
74
+ def sync_options(options)
75
+ options
76
+ end
77
+ end
78
+ include SyncOptions
79
+
80
+
81
+ # Excludes :virtual and :writeable: false properties from #sync in this twin.
82
+ module Writeable
83
+ def sync_options(options)
84
+ options = super
85
+
86
+ protected_fields = schema.each.find_all { |d| d[:writeable] == false }.collect { |d| d.name }
87
+ options.exclude!(protected_fields)
88
+ end
89
+ end
90
+ include Writeable
91
+
92
+
93
+ # This will skip unchanged properties in #sync. To use this for all nested form do as follows.
94
+ #
95
+ # class SongForm < Reform::Form
96
+ # feature Sync::SkipUnchanged
97
+ module SkipUnchanged
98
+ def self.included(base)
99
+ base.send :include, Disposable::Twin::Changed
100
+ end
101
+
102
+ def sync_options(options)
103
+ # DISCUSS: we currently don't track if nested forms have changed (only their attributes). that's why i include them all here, which
104
+ # is additional sync work/slightly wrong. solution: allow forms to form.changed? not sure how to do that with collections.
105
+ scalars = schema.each(scalar: true).collect { |dfn| dfn.name }
106
+ unchanged = scalars - changed.keys
107
+
108
+ # exclude unchanged scalars, nested forms and changed scalars still go in here!
109
+ options.exclude!(unchanged)
110
+ super
111
+ end
112
+ end
113
+ end
114
+ end
@@ -1,3 +1,3 @@
1
1
  module Disposable
2
- VERSION = "0.0.9"
2
+ VERSION = "0.1.0"
3
3
  end
@@ -0,0 +1,263 @@
1
+ require "test_helper"
2
+ require "disposable/twin"
3
+ require "ostruct"
4
+
5
+ module Model
6
+ Song = Struct.new(:id, :title)
7
+ Album = Struct.new(:id, :name, :songs)
8
+ end
9
+
10
+ # thoughts:
11
+ # a twin should be a proxy between the incoming API instructions (form hash) and the models to write to.
12
+ # e.g. when deleting certain items in a collection, this could be held in memory before written to DB.
13
+ # reason: a twin can be validated (e.g. is current user allowed to remove item 1 from collection abc?)
14
+ # before the application state is actually altered in the DB.
15
+ # that would open a clean workflow: API calls --> twin state change --> validation --> "rollback" / save
16
+
17
+ module Representable
18
+ class Semantics
19
+ class Semantic
20
+ def self.existing_item_for(fragment, options)
21
+ # return unless model.songs.collect { |s| s.id.to_s }.include?(fragment["id"].to_s)
22
+ options.binding.get.find { |s| s.id.to_s == fragment["id"].to_s }
23
+ end
24
+ end
25
+
26
+ class SkipExisting < Semantic
27
+ def self.call(model, fragment, index, options)
28
+ return unless existing_item_for(fragment, options)
29
+
30
+ Skip.new(fragment)
31
+ end
32
+ end
33
+
34
+ class Add < Semantic # old representable behavior.
35
+ def self.call(model, fragment, index, options)
36
+ binding = options.binding.clone
37
+ binding.instance_variable_get(:@definition).delete!(:instance) # FIXME: sucks!
38
+ Representable::Deserializer.new(binding).(fragment, options.user_options) # Song.new
39
+ end
40
+ end
41
+
42
+ class UpdateExisting < Semantic
43
+ def self.call(model, fragment, index, options)
44
+ return unless res = existing_item_for(fragment, options)
45
+
46
+ Update.new(res)
47
+ end
48
+ end
49
+
50
+
51
+ class Skip < OpenStruct
52
+ end
53
+
54
+ class Remove < Skip
55
+ def self.call(model, fragment, index, options)
56
+ return unless fragment["_action"] == "remove" # TODO: check if feature enabled.
57
+
58
+ Remove.new(fragment)
59
+ end
60
+ end
61
+
62
+ require 'delegate'
63
+ class Update < SimpleDelegator
64
+ end
65
+
66
+ # Per parsed collection item, mark the to-be-populated model for removal, skipping or adding.
67
+ # This code is called right before #from_format is called on the model.
68
+ # Semantical behavior is inferred from the fragment making this code document- and format-specific.
69
+
70
+ # remove: unlink from association
71
+ # skip_existing
72
+ # update_existing
73
+ # add
74
+ # [destroy]
75
+ # callable
76
+
77
+ # default behavior: - add_new
78
+
79
+ class Instance
80
+ include Uber::Callable
81
+
82
+ def call(model, fragment, index, options)
83
+ semantics = options.binding[:semantics]
84
+
85
+ # loop through semantics, the first that returns something wins.
86
+ semantics.each do |semantic|
87
+ res = semantic.(model, fragment, index, options) and return res
88
+ end
89
+ end
90
+ end
91
+
92
+ class Setter
93
+ include Uber::Callable
94
+
95
+ def call(model, values, options)
96
+ remove_items = values.find_all { |i| i.instance_of?(Representable::Semantics::Remove) }
97
+ # add_items = values.find_all { |i| i.instance_of?(Add) }.collect(&:model)
98
+ add_items = values - remove_items
99
+
100
+ skip_items = values.find_all { |i| i.instance_of?(Representable::Semantics::Skip) }
101
+ skip_items += values.find_all { |i| i.instance_of?(Representable::Semantics::Update) } # TODO: merge with above!
102
+
103
+ # add_items = values.find_all { |i| i.instance_of?(Add) }.collect(&:model)
104
+ add_items = add_items - skip_items
105
+
106
+ # DISCUSS: collection#[]= will call save
107
+ # what does #+= and #-= do?
108
+ # how do we prevent adding already existing items twice?
109
+
110
+ model.songs += add_items
111
+ model.songs -= remove_items.collect { |i| model.songs.find { |s| s.id.to_s == i.id.to_s } }
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ class AlbumDecorator < Representable::Decorator
118
+ include Representable::Hash
119
+
120
+ collection :songs,
121
+
122
+ # semantics: [:skip_existing, :add, :remove],
123
+ semantics: [Representable::Semantics::Remove, Representable::Semantics::SkipExisting, Representable::Semantics::Add],
124
+
125
+ instance: Representable::Semantics::Instance.new,
126
+ pass_options: true,
127
+ setter: Representable::Semantics::Setter.new,
128
+
129
+
130
+ class: Model::Song do # add new to existing collection.
131
+
132
+ # only add new songs
133
+ property :title
134
+ end
135
+ end
136
+
137
+
138
+
139
+ class ApiSemanticsTest < MiniTest::Spec
140
+ it "xxx" do
141
+ album = Model::Album.new(1, "And So I Watch You From Afar", [Model::Song.new(2, "Solidarity"), Model::Song.new(0, "Tale That Wasn't Right")])
142
+
143
+ decorator = AlbumDecorator.new(album)
144
+ decorator.from_hash({"songs" => [
145
+ {"id" => 2, "title" => "Solidarity, but wrong title"}, # skip
146
+ {"id" => 0, "title" => "Tale That Wasn't Right, but wrong title", "_action" => "remove"}, # delete
147
+ {"id" => 4, "title" => "Capture Castles"} # add, default.
148
+ ]})
149
+ # missing: allow updating specific/all items in collection.
150
+
151
+ decorator.represented.songs.inspect.must_equal %{[#<struct Model::Song id=2, title="Solidarity">, #<struct Model::Song id=nil, title="Capture Castles">]}
152
+ end
153
+
154
+ end
155
+
156
+ class RemoveFlagSetButNotEnabled < MiniTest::Spec
157
+ class AlbumDecorator < Representable::Decorator
158
+ include Representable::Hash
159
+
160
+ collection :songs,
161
+ # semantics: [:skip_existing, :add, :remove],
162
+ semantics: [Representable::Semantics::SkipExisting, Representable::Semantics::Add],
163
+
164
+ instance: Representable::Semantics::Instance.new,
165
+ pass_options: true,
166
+ setter: Representable::Semantics::Setter.new,
167
+ class: Model::Song do
168
+ property :title
169
+ end
170
+ end
171
+
172
+ it "doesn't remove when semantic is not enabled" do
173
+ album = Model::Album.new(1, "And So I Watch You From Afar", [Model::Song.new(2, "Solidarity"), Model::Song.new(0, "Tale That Wasn't Right")])
174
+
175
+ decorator = AlbumDecorator.new(album)
176
+ decorator.from_hash({"songs" => [
177
+ {"id" => 2, "title" => "Solidarity, updated!"}, # update
178
+ {"id" => 0, "title" => "Tale That Wasn't Right, but wrong title", "_action" => "remove"}, # delete, but don't!
179
+ {"title" => "Rise And Fall"}
180
+ ]})
181
+
182
+ decorator.represented.songs.inspect.must_equal %{[#<struct Model::Song id=2, title=\"Solidarity\">, #<struct Model::Song id=0, title=\"Tale That Wasn't Right\">, #<struct Model::Song id=nil, title=\"Rise And Fall\">]}
183
+ end
184
+ end
185
+
186
+ class UserCallableTest < MiniTest::Spec
187
+ class MyOwnSemantic < Representable::Semantics::Semantic
188
+ def self.call(model, fragment, index, options)
189
+ if fragment["title"] =~ /Solidarity/
190
+ return Representable::Semantics::Skip.new(fragment)
191
+ end
192
+ end
193
+ end
194
+
195
+ class AlbumDecorator < Representable::Decorator
196
+ include Representable::Hash
197
+
198
+ collection :songs,
199
+ semantics: [MyOwnSemantic, Representable::Semantics::Add],
200
+
201
+ instance: Representable::Semantics::Instance.new,
202
+ pass_options: true,
203
+ setter: Representable::Semantics::Setter.new,
204
+ class: Model::Song do
205
+ property :title
206
+ end
207
+ end
208
+
209
+ it do
210
+ album = Model::Album.new(1, "And So I Watch You From Afar", [Model::Song.new(2, "Solidarity"), Model::Song.new(0, "Tale That Wasn't Right")])
211
+
212
+ decorator = AlbumDecorator.new(album)
213
+ decorator.from_hash({"songs" => [
214
+ {"id" => 2, "title" => "Solidarity, updated!"}, # update
215
+ {"title" => "Rise And Fall"}
216
+ ]})
217
+
218
+ decorator.represented.songs.inspect.must_equal %{[#<struct Model::Song id=2, title=\"Solidarity\">, #<struct Model::Song id=0, title=\"Tale That Wasn't Right\">, #<struct Model::Song id=nil, title=\"Rise And Fall\">]}
219
+ end
220
+ end
221
+
222
+
223
+ class ApiSemanticsWithUpdate < MiniTest::Spec
224
+ class AlbumDecorator < Representable::Decorator
225
+ include Representable::Hash
226
+
227
+ collection :songs,
228
+
229
+ semantics: [Representable::Semantics::Remove, Representable::Semantics::UpdateExisting, Representable::Semantics::Add],
230
+
231
+ instance: Representable::Semantics::Instance.new,
232
+ pass_options: true,
233
+ class: Model::Song,
234
+
235
+ setter: Representable::Semantics::Setter.new do # add new to existing collection.
236
+
237
+ # only add new songs
238
+ property :title
239
+ end
240
+ end
241
+
242
+ it do
243
+ album = Model::Album.new(1, "And So I Watch You From Afar", [Model::Song.new(2, "Solidarity"), Model::Song.new(0, "Tale That Wasn't Right")])
244
+
245
+ decorator = AlbumDecorator.new(album)
246
+ decorator.from_hash({"songs" => [
247
+ {"id" => 2, "title" => "Solidarity, updated!"}, # update
248
+ {"id" => 0, "title" => "Tale That Wasn't Right, but wrong title", "_action" => "remove"}, # delete
249
+ {"id" => 4, "title" => "Capture Castles"}, # add, default. # FIXME: this tests adding with id, keep this.
250
+ {"title" => "Rise And Fall"}
251
+ ]})
252
+ # missing: allow updating specific/all items in collection.
253
+
254
+ puts decorator.represented.songs.inspect
255
+
256
+
257
+ decorator.represented.songs.inspect.must_equal %{[#<struct Model::Song id=2, title="Solidarity, updated!">, #<struct Model::Song id=nil, title="Capture Castles">, #<struct Model::Song id=nil, title=\"Rise And Fall\">]}
258
+ end
259
+ end
260
+ # [
261
+ # {"_action": "add"},
262
+ # {"id": 2, "_action": "remove"}
263
+ # ]