-
Notifications
You must be signed in to change notification settings - Fork 0
/
backbone.composite-model.js
396 lines (373 loc) · 15.8 KB
/
backbone.composite-model.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
// Backbone.CompositeModel 0.1.7
// https://github.com/prantlf/backbone.composite-model
//
// Copyright (c) 2015-2017 Ferdinand Prantl
// Licensed under the MIT license.
//
// Supports composite Backbone.Model objects which consist of a main model
// and child models or collections maintained automatically according to a
// composite configuration
// Example
// -------
// Let's have a versioned file-system model: folders which contain
// sub-folders and files, files consisting of file versions. The REST
// API resource `/files/:id` returns the following (simplified) response
// about a file:
//
// {
// id: ..., // globally unique ID
// name: '...', // file display name
// parent_expanded: {...}, // parent folder object
// versions: [...] // version object array
// }
//
// Let's declare the following (simplified) models and collections for them:
//
// var FolderModel = Backbone.Model.extend({...});
//
// var VersionCollection = Backbone.Collection.extend({...});
//
// var FileModel = Backbone.Model.extend({...})
//
// // Declare what attributes from the response back
// // up what child models and collection stored in
// // properties on the object instance
// composite: {
// parent_expanded: {
// model: FolderModel,
// property: 'parent'
// },
// versions: VersionCollection,
// },
//
// // Override the constructor to see the name `FileModel`
// // in the debugger and to be able to initialize the
// // composite model
// constructor: function FileModel(attributes, options) {
// FileModel.__super__.constructor.apply(this, arguments);
// this,makeComposite(options);
// },
//
// // Point to the resource representing the file
// // on the server
// urlRoot: '/files'
//
// });
//
// // Extend the function object prototype to become
// // a composite of child models and collections
// Backbone.mixinCompositeModel(FileModel.prototype);
//
// This lets the `parent` and `versions` properties maintained automatically
// without an additional code.
//
// var file = new FileModel({id: ...});
// file.fetch()
// .done(function () {
// console.log('Name:', file.get('name'));
// // This does not work.
// console.log('Parent folder:', file.parent.get('name'));
// console.log('Version count:', file.versions.length);
// });
//
// The `parent` object and the `versions` array are be accessible as
// `Backbone.Model` and `Backbone.Collection` to be able to pass them to
// other models and views and listen to their events in the application
// using a common Backbone interface.
// Module Factory
// --------------
// UMD wrapper to support multiple module dependency loaders
(function (root, factory) {
'use strict';
// Handle AMD modules (RequireJS)
if (typeof define === 'function' && define.amd) {
define(['underscore', 'backbone'], factory);
// Handle CommonJS modules (NodeJS)
} else if (typeof module === 'object' && module.exports) {
module.exports = factory(require('underscore'), require('backbone'));
// Handle global variables setting modules (web browser alone)
} else {
root.returnExports = factory(root._, root.Backbone);
}
}(this, function (_, Backbone) {
'use strict';
// Mixin Function
// --------------
//
// Applied on a `Backbone.Model` prototype, extends it to support model
// composites made of child models and collections, which can be
// configured by the `composite` property from the object prototype,
// instance or constructor `options`.
//
// Members of the `composite` object (map):
//
// // Maintains a property on the model instance with
// // the attribute name pointing to an object instance
// // of the specified type
// <attribute name>: <Backbone model or collection>,
//
// // Maintains a property on the model instance backed
// // up by the specified attribute overriding the
// // default handling
// <attribute name>: {
// // Model or collection to create for the attribute
// // value (required)
// type: <Backbone model or collection>,
// // Property name to store the sub-object on the
// // model with (optional; the attribute name is
// // the default)
// property: <property name>,
// // Additional options to pass to the constructor of
// // the sub-object (optional; undefined by default)
// options: <object literal>,
// // Method to call on the child model or collection
// // if updated data are being set (optional; `set` is
// // the default for models, `add` for collections)
// method: <method name>,
// // Function to call before the value is passed to
// // child model or collection constructor or `set` /
// // `add` method to "massage" the input data
// // (optional; `undefined` is the default)
// parse: <function (value, options)>,
// }
//
Backbone.mixinCompositeModel = function (prototype) {
var originalSet = prototype.set,
originalToJSON = prototype.toJSON;
return _.extend(prototype, {
// Initialization Function
// -----------------------
// Initializes the composite model support; to be called from the
// `initialize` method or from the overridden constructor, after
// the parent constructor has been called
makeComposite: function (options) {
// Mark the object creation scenario for the _updateComposite below
options = _.extend({create: true}, options);
this._compositeMap = this._createCompositeMap(options);
// Update properties explicitly, because the `set` method with
// the initial attributes of the model is called in the parent
// constructor already, before the `_compositeMap` has been
// called here, thus the composite extension could not apply yet
this._updateComposite(this.attributes, options);
return this;
},
// Overridden Functions
// -------------------
// Overrides the `Backbone.Model:set()` method to ensure that the
// nested attribute values will be propagated to the child models
// and collections of this composite
set: function (key, value, options) {
var attributes;
// Do nothing if nought has been asked for
if (key == null) {
return this;
}
// Normalize the input parameters to become two object literals:
// handle both `'key', value` and `{key: value}` -style arguments
if (typeof key === 'object') {
attributes = key;
options = value;
} else {
(attributes = {})[key] = value;
}
options || (options = {});
// Set the common attributes and check the result first
var result = originalSet.call(this, attributes, options);
// Update the child models and collections if the composite
// map has been initialized (after the constructor has finished)
if (result && this._compositeMap) {
this._updateComposite(attributes, options);
}
// Return the same result as the original `set` method
return result;
},
// Overrides the `Backbone.Model:toJSON()` method to ensure that the
// up-to-date nested attribute values will be present in the result
toJSON: function(options) {
var result = originalToJSON.call(this, options);
// Update keys maintained by child models and collections only if
// the composite map has been initialized (after the constructor
// has finished)
if (this._compositeMap) {
// Process only attributes listed in the composite map
_.each(this._compositeMap, function (composite, key) {
// Get the nested model or collection for the composite item
var child = this[composite.property];
// If the nested model or collection is available, propagate
// its current content to the resulting JSON
if (child) {
result[key] = child.toJSON();
}
}, this);
}
return result;
},
// Private Functions
// -----------------
// Merges prototype.composite and options.composite and normalizes
// the child model or collection configuration
_createCompositeMap: function (options) {
// Allow specifying the composite property as a function returning
// the actual configuration object
var thisComposite = this.composite,
optionsComposite = options.composite;
if (typeof thisComposite === 'function') {
thisComposite = thisComposite.call(this, options);
}
if (typeof optionsComposite === 'function') {
optionsComposite = optionsComposite.call(this, options);
}
if (thisComposite && typeof thisComposite !== 'object' ||
optionsComposite && typeof optionsComposite !== 'object') {
throw new Error('Invalid composite configuration');
}
// Allow the caller to specify additional or override existing
// attribute rules defined in the prototype or in the instance
var composite = _.extend({}, thisComposite, optionsComposite);
return _.reduce(composite, function (result, model, attribute) {
var property, parse, method;
// Just model or collection function object can be used for
// convenience
if (model.prototype instanceof Backbone.Model ||
model.prototype instanceof Backbone.Collection) {
property = attribute;
// Otherwise the child model or collection object descriptor
// should be an object literal with configuration properties
} else {
if (typeof model !== 'object') {
throw new Error('Invalid composite child descriptor');
}
// Attribute name is the default for the property name
property = model.property || attribute;
// Make sure that the extra data parsing function is not set
// or is a valid function
parse = model.parse;
if (parse != null && typeof parse !== 'function') {
throw new Error('Invalid child model data parse function');
}
method = model.method;
// Make sure that the child model or collection type is valid
model = model.type;
if (!(model.prototype instanceof Backbone.Model ||
model.prototype instanceof Backbone.Collection)) {
throw new Error('Invalid composite child model');
}
}
// Avoid replacing an existing prototype member with the child
// model or collection instance
if (prototype[property]) {
throw new Error('Property conflict in the composite prototype');
}
// Use the default data updating method if not specified
if (!method) {
method = model.prototype instanceof Backbone.Model ? 'set' : 'add';
}
// Make sure that the data updating method exists in the child
// model or collection prototype
if (!model.prototype[method]) {
throw new Error('Invalid chidl model data updating method');
}
// Make every map entry look consistent
result[attribute] = {
model: model,
property: property,
parse: parse,
method: method
};
return result;
}, {});
},
// Checks if the changed attributes contained a key, which backs up
// a child model or collection and updates the child object
// accordingly
_updateComposite: function (attributes, options) {
// Creates a new instance of the child model or collection
function create(composite, parameters) {
var createOptions = _.extend({}, composite.options, options);
this[composite.property] = new composite.model(parameters,
createOptions);
}
// Ensures that the property with the child model or collection
// exists and clears it if
function createOrClear(composite) {
var child = this[composite.property];
if (child) {
// Clearing an attribute on the main model should clear the
// child model or collection; requesting the `parse` option
// gives a hint about re-fetching the entire model, which
// should do the same, but not when saving; the server may
// respond with incomplete model attributes
//
// TODO: How to handle `fetch` with suppressed `parse`?
// TODO: How to handle `save` with suppressed validation?
if (options.unset || options.parse && !options.validate) {
if (child instanceof Backbone.Model) {
child.clear(options);
} else {
child.reset(undefined, options);
}
}
} else if (options.create) {
// If called from the constructor or with an undefined or with
// an explicit null, force creation of empty child models and
// collections, at least
create.call(this, composite);
}
}
// Propagates the attribute change to the child model ort collection
function populate(composite, value) {
// Pre-process the attributes or models before they are
// propagated to the child object
if (composite.parse) {
value = composite.parse.call(this, value, options);
}
// If called from the constructor, the property will not exist
var child = this[composite.property];
if (child) {
// When the main model is re-fetched, the child models or
// collections should be reset; requesting the `parse` option
// gives a hint about it and the `validate` option discloses
// the saving; the server may respond with an incomplete data
//
// TODO: How to handle `fetch` with suppressed `parse`?
// TODO: How to handle `save` with suppressed validation?
if (options.parse && !options.validate) {
if (child instanceof Backbone.Model) {
var missing = _.omit(child.attributes, _.keys(value));
child.set(missing, {
unset: true,
silent: true
});
} else {
child.reset(undefined, {silent: true});
}
}
child[composite.method](value, options);
} else {
create.call(this, composite, value);
}
}
// Process only attributes listed in the composite map
_.each(this._compositeMap, function (composite, key) {
// Leave the child model or collection intact if the attributes
// do not contain its key
if (_.has(attributes, key)) {
var value = attributes[key];
if (value != null) {
populate.call(this, composite, value);
} else {
createOrClear.call(this, composite);
}
} else {
// If called from the constructor without attributes, force
// creation of empty child models and collections, at least
createOrClear.call(this, composite);
}
}, this);
}
});
};
// Export the function to apply the mixin to a prototype either as a result
// of this module callback or as a property on the `Backbone` object
return Backbone.mixinCompositeModel;
}));