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.
- checksums.yaml +4 -4
- data/.travis.yml +2 -5
- data/CHANGES.md +4 -0
- data/Gemfile +1 -1
- data/README.md +154 -1
- data/database.sqlite3 +0 -0
- data/disposable.gemspec +7 -7
- data/gemfiles/Gemfile.rails-3.0.lock +10 -8
- data/gemfiles/Gemfile.rails-3.2.lock +9 -7
- data/gemfiles/Gemfile.rails-4.0.lock +9 -7
- data/gemfiles/Gemfile.rails-4.1.lock +10 -8
- data/lib/disposable.rb +6 -7
- data/lib/disposable/callback.rb +174 -0
- data/lib/disposable/composition.rb +21 -58
- data/lib/disposable/expose.rb +49 -0
- data/lib/disposable/twin.rb +85 -38
- data/lib/disposable/twin/builder.rb +12 -30
- data/lib/disposable/twin/changed.rb +50 -0
- data/lib/disposable/twin/collection.rb +95 -0
- data/lib/disposable/twin/composition.rb +43 -15
- data/lib/disposable/twin/option.rb +1 -1
- data/lib/disposable/twin/persisted.rb +20 -0
- data/lib/disposable/twin/property_processor.rb +29 -0
- data/lib/disposable/twin/representer.rb +42 -14
- data/lib/disposable/twin/save.rb +19 -34
- data/lib/disposable/twin/schema.rb +31 -0
- data/lib/disposable/twin/setup.rb +38 -0
- data/lib/disposable/twin/sync.rb +114 -0
- data/lib/disposable/version.rb +1 -1
- data/test/api_semantics_test.rb +263 -0
- data/test/callback_group_test.rb +222 -0
- data/test/callbacks_test.rb +450 -0
- data/test/example.rb +40 -0
- data/test/expose_test.rb +92 -0
- data/test/persisted_test.rb +101 -0
- data/test/test_helper.rb +64 -0
- data/test/twin/benchmarking.rb +33 -0
- data/test/twin/builder_test.rb +32 -0
- data/test/twin/changed_test.rb +108 -0
- data/test/twin/collection_test.rb +223 -0
- data/test/twin/composition_test.rb +56 -25
- data/test/twin/expose_test.rb +73 -0
- data/test/twin/feature_test.rb +61 -0
- data/test/twin/from_test.rb +37 -0
- data/test/twin/inherit_test.rb +57 -0
- data/test/twin/option_test.rb +27 -0
- data/test/twin/readable_test.rb +57 -0
- data/test/twin/save_test.rb +192 -0
- data/test/twin/schema_test.rb +69 -0
- data/test/twin/setup_test.rb +139 -0
- data/test/twin/skip_unchanged_test.rb +64 -0
- data/test/twin/struct_test.rb +168 -0
- data/test/twin/sync_option_test.rb +228 -0
- data/test/twin/sync_test.rb +128 -0
- data/test/twin/twin_test.rb +49 -128
- data/test/twin/writeable_test.rb +56 -0
- metadata +106 -20
- data/STUFF +0 -4
- data/lib/disposable/twin/finders.rb +0 -29
- data/lib/disposable/twin/new.rb +0 -30
- data/lib/disposable/twin/save_.rb +0 -21
- 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
|
data/lib/disposable/version.rb
CHANGED
@@ -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
|
+
# ]
|