disposable 0.0.9 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
+ # ]