diff --git a/map/attributes/attributes-assocations.html b/map/attributes/attributes-assocations.html index 6096aa30ab1..0c925b69137 100644 --- a/map/attributes/attributes-assocations.html +++ b/map/attributes/attributes-assocations.html @@ -64,7 +64,7 @@ }; // A task model that has a date -can.Model("Task",{ +var Task = can.Model({ attributes : { due : 'date' } @@ -76,10 +76,10 @@ }); // A contact model that has many tasks -can.Model("Contact",{ +var Contact = can.Model({ attributes : { birthday : 'date', - tasks: "Task.models" + tasks: Task }, findAll : "/contacts.json" },{ diff --git a/map/attributes/attributes.js b/map/attributes/attributes.js index fe213e2674d..0cfb9649e95 100644 --- a/map/attributes/attributes.js +++ b/map/attributes/attributes.js @@ -1,5 +1,4 @@ -steal('can/util', 'can/map', function(can, Observe) { - +steal('can/util', 'can/map', function(can, Map) { can.each([ can.Map, can.Model ], function(clss){ // in some cases model might not be defined quite yet. if(clss === undefined){ @@ -8,7 +7,7 @@ can.each([ can.Map, can.Model ], function(clss){ var isObject = function( obj ) { return typeof obj === 'object' && obj !== null && obj; }; - + can.extend(clss, { /** * @property can.Map.attributes.static.attributes attributes @@ -16,7 +15,7 @@ can.each([ can.Map, can.Model ], function(clss){ * * `can.Map.attributes` is a property that contains key/value pair(s) of an attribute's name and its * respective type for using in [can.Map.attributes.static.convert convert] and [can.Map.prototype.serialize serialize]. - * + * * var Contact = can.Map.extend({ * attributes : { * birthday : 'date', @@ -27,13 +26,13 @@ can.each([ can.Map, can.Model ], function(clss){ * */ attributes : {}, - + /** * @property can.Map.attributes.static.convert convert * @parent can.Map.attributes.static * - * You often want to convert from what the observe sends you to a form more useful to JavaScript. - * For example, contacts might be returned from the server with dates that look like: "1982-10-20". + * You often want to convert from what the observe sends you to a form more useful to JavaScript. + * For example, contacts might be returned from the server with dates that look like: "1982-10-20". * We can observe to convert it to something closer to `new Date(1982,10,20)`. * * Convert comes with the following types: @@ -69,13 +68,13 @@ can.each([ can.Map, can.Model ], function(clss){ * var contact = new Contact(); * * //- calls convert on attribute set - * contact.attr('birthday', '4-26-2012') + * contact.attr('birthday', '4-26-2012') * * contact.attr('birthday'); //-> Date - * + * * If a property is set with an object as a value, the corresponding converter is called with the unmerged data (the raw object) * as the first argument, and the old value (a can.Map) as the second: - * + * * var MyObserve = can.Map.extend({ * attributes: { * nested: "nested" @@ -91,9 +90,9 @@ can.each([ can.Map, can.Model ], function(clss){ * },{}); * * ## Differences From `attr` - * + * * The way that return values from convertors affect the value of an Observe's property is - * different from [can.Map::attr attr]'s normal behavior. Specifically, when the + * different from [can.Map::attr attr]'s normal behavior. Specifically, when the * property's current value is an Observe or List, and an Observe or List is returned * from a convertor, the effect will not be to merge the values into the current value as * if the return value was fed straight into `attr`, but to replace the value with the @@ -102,7 +101,7 @@ can.each([ can.Map, can.Model ], function(clss){ * * If you would rather have the new Observe or List merged into the current value, call * `attr` directly on the property instead of on the Observe: - * + * * @codestart * var Contact = can.Map.extend({ * attributes: { @@ -114,15 +113,15 @@ can.each([ can.Map, can.Model ], function(clss){ * } * } * }, {}); - * + * * var alice = new Contact({info: {name: 'Alice Liddell', email: 'alice@liddell.com'}}); * alice.attr(); // {name: 'Alice Liddell', 'email': 'alice@liddell.com'} * alice.info._cid; // '.observe1' - * + * * alice.attr('info', {name: 'Allison Wonderland', phone: '888-888-8888'}); * alice.attr(); // {name: 'Allison Wonderland', 'phone': '888-888-8888'} * alice.info._cid; // '.observe2' - * + * * alice.info.attr({email: 'alice@wonderland.com', phone: '000-000-0000'}); * alice.attr(); // {name: 'Allison Wonderland', email: 'alice@wonderland.com', 'phone': '000-000-0000'} * alice.info._cid; // '.observe2' @@ -132,11 +131,11 @@ can.each([ can.Map, can.Model ], function(clss){ * * If you have assocations defined within your model(s), you can use convert to automatically * call serialize on those models. - * + * * @codestart * var Contact = can.Model.extend({ * attributes : { - * tasks: "Task.models" + * tasks: Task * } * }, {}); * @@ -151,8 +150,8 @@ can.each([ can.Map, can.Model ], function(clss){ * due: new Date() * }) ] * }); - * - * contact.serialize(); + * + * contact.serialize(); * //-> { tasks: [ { due: 1333219754627 } ] } * @codeend */ @@ -178,9 +177,27 @@ can.each([ can.Map, can.Model ], function(clss){ return true; }, "default": function( val, oldVal, error, type ) { + // Convert can.Model types using .model and .models + if(can.Map.prototype.isPrototypeOf(type.prototype) && + typeof type.model === 'function' && typeof type.models === 'function') { + return type[can.isArray(val) ? 'models' : 'model'](val); + } + + if(can.Map.prototype.isPrototypeOf(type.prototype)) { + if(can.isArray(val) && typeof type.List === 'function') { + return new type.List(val); + } + return new type(val); + } + + if(typeof type === 'function') { + return type(val, oldVal); + } + var construct = can.getObject(type), context = window, realType; + // if type has a . we need to look it up if ( type.indexOf(".") >= 0 ) { // get everything before the last . @@ -195,14 +212,14 @@ can.each([ can.Map, can.Model ], function(clss){ * @property can.Map.attributes.static.serialize serialize * @parent can.Map.attributes.static * - * `can.Map.serialize` is an object of name-function pairs that are used to + * `can.Map.serialize` is an object of name-function pairs that are used to * serialize attributes. * - * Similar to [can.Map.attributes.static.convert can.Map.attributes.convert], in that the keys of this object correspond to + * Similar to [can.Map.attributes.static.convert can.Map.attributes.convert], in that the keys of this object correspond to * the types specified in [can.Map.attributes]. * - * By default every attribute will be passed through the 'default' serialization method - * that will return the value if the property holds a primitive value (string, number, ...), + * By default every attribute will be passed through the 'default' serialization method + * that will return the value if the property holds a primitive value (string, number, ...), * or it will call the "serialize" method if the property holds an object with the "serialize" method set. * * For example, to serialize all dates to ISO format: @@ -218,8 +235,8 @@ can.each([ can.Map, can.Model ], function(clss){ * } * } * },{}); - * - * var contact = new Contact({ + * + * var contact = new Contact({ * birthday: new Date("Oct 25, 1973") * }).serialize(); * //-> { "birthday" : "1973-10-25T05:00:00.000Z" } @@ -235,10 +252,10 @@ can.each([ can.Map, can.Model ], function(clss){ } } }); - + // overwrite setup to do this stuff var oldSetup = clss.setup; - + /** * @hide * @function can.Map.setup @@ -277,16 +294,16 @@ can.Map.prototype.__convert = function(prop, value){ var Class = this.constructor, oldVal = this.attr(prop), type, converter; - + if(Class.attributes){ // the type of the attribute type = Class.attributes[prop]; converter = Class.convert[type] || Class.convert['default']; } - - return value === null || !type ? + + return value === null || !type ? // just use the value - value : + value : // otherwise, pass to the converter converter.call(Class, value, oldVal, function() {}, type); }; @@ -295,7 +312,7 @@ can.Map.prototype.__convert = function(prop, value){ * @function can.Map.prototype.attributes.serialize serialize * @parent can.Map.attributes.prototype * - * @description Serializes the observe's properties using + * @description Serializes the observe's properties using * the [can.Map.attributes attribute plugin]. * * @signature `observe.serialize([attrName])` @@ -351,7 +368,7 @@ can.Map.prototype.serialize = function(attrName, stack) { // Since this object has already been serialized once, // just reference the id (or undefined if it doesn't exist). where[name] = val.attr('id'); - } + } else { type = Class.attributes ? Class.attributes[name] : 0; converter = Class.serialize ? Class.serialize[type] : 0; diff --git a/map/attributes/attributes.md b/map/attributes/attributes.md index 442df01b9d8..a288882bcbc 100644 --- a/map/attributes/attributes.md +++ b/map/attributes/attributes.md @@ -157,24 +157,72 @@ as well. Lastly, `serialize` is invoked converting the new attributes to raw ty //-> { 'birthday': '11-29-1983', 'weight': '300' } - +## Converter functions + +Another common case is to create converter functions (`function(value, oldValue) {}`) that return a converted value: + + var ValueMap = can.Map.extend({ + attributes: { + value: function(orig) { + return orig * 100; + } + } + },{}); + + console.log(new ValueMap({ value: 0.83 }).attr('value')); + + ## Associations -Attribute type values can also represent the name of a function. The most common case this is used is for associated data. +The attribute plugin also allows setting up data associations between Maps or Models. This means +that nested data structures can be automatically converted into their Map or Model (using `Model.models`) representations by passing them as the attribute. +If the value to convert is an array it will be converted into its `can.Map.List` or `can.Model.List` (using `can.Model.models`) representation: -For example, a `Deliverable` might have many tasks and an owner (which is a Person). The attributes property might look like: + var Sword = can.Model.extend({ + findAll: 'GET /swords' + }, { + getPower: function() { + return this.attr('power') * 100; + } + }); - var Deliverable = new can.Map.extend({ - attributes : { - tasks : "App.Models.Task.models" - owner: "App.Models.Person.model" + var Level = can.Model.extend({ + findAll: 'GET /levels' + }, { + getName: function() { + return 'Level: ' + this.attr('name'); } - },{}); + }); + + var Zelda = can.Model.extend({ + findOne: 'GET /zelda/{id}' + attributes: { + sword: Sword, + levelsCompleted: Level + } + },{}); -This points tasks and owner properties to use _Task_ and _Person_ to convert the raw data into an array of Tasks and a Person. -It's important to note that the full names of the models themselves are _App.Models.Task_ and _App.Models.Person_. The `.model` -and `.models` parts are appended for the benefit of convert to identify the types as models. +Assuming that `Zelda.findOne({ id: 'link' })` will return something like: + + { + sword: { + name: 'Wooden Sword', + power: 0.2 + }, + levelsCompleted : [ + {id: 1, name: 'Aquamentus'}, + {id: 2, name: 'Dodongo'} + ] + } + +The converted data will contain a list or Levels and a sword Model: + + Zelda.findOne({ id: 'link' }).then(function(link) { + console.log(link.attr('sword').getPower()); // -> 20 + console.log(link.attr('levelsCompleted')[0].getName()); + // -> 'Level: Aquamentus' + }); ### Demo diff --git a/map/attributes/attributes_test.js b/map/attributes/attributes_test.js index 3babdd14009..2373fe5f32d 100644 --- a/map/attributes/attributes_test.js +++ b/map/attributes/attributes_test.js @@ -594,5 +594,103 @@ test("store instances (#457)", function() { can.Model._reqs--; }); +test("Converter functions", function() { + var Value = can.Map.extend({ + attributes: { + value: function(orig) { + return orig * 100; + } + } + }, {}); + + var testValue = new Value({ value: 0.823 }); + + equal(testValue.attr('value'), 82.3, 'Value got multiplied'); +}); + +test("Convert can.Map constructs passed as attributes (#293)", 4, function() { + var Sword = can.Map.extend({ + getPower: function() { + return this.attr('power') * 100; + } + }); + + var Level = can.Map.extend({ + getName: function() { + return 'Level: ' + this.attr('name'); + } + }); + + var Zelda = can.Map.extend({ + attributes: { + sword: Sword, + levelsCompleted: Level + } + },{}); + + var link = new Zelda({ + sword: { + name: 'Wooden Sword', + power: 0.2 + }, + levelsCompleted : [ + {id: 1, name: 'Aquamentus'}, + {id: 2, name: 'Dodongo'} + ] + }); + + ok(link.attr('sword') instanceof Sword, 'Sword got converted'); + equal(link.attr('sword').getPower(), 20, 'Got sword power!'); + ok(link.attr('levelsCompleted') instanceof Level.List, 'Got a level list'); + equal(link.attr('levelsCompleted.0').getName(), 'Level: Aquamentus', 'Entry got converted as well'); +}); + +test("Convert can.Model using .model and .models (#293)", 5, function() { + var Sword = can.Model.extend({ + findAll: 'GET /swords', + model: function(data) { + data.test = 'Used .model' + return new this(data); + } + }, { + getPower: function() { + return this.attr('power') * 100; + } + }); + + var Level = can.Model.extend({ + findAll: 'GET /levels', + models: function(array) { + can.each(array, function(current, index) { + current.index = index; + }); + return can.Model.models.call(this, array); + } + }, {}); + + var Zelda = can.Model.extend({ + attributes: { + sword: Sword, + levelsCompleted: Level + } + },{}); + + var link = Zelda.model({ + sword: { + name: 'Wooden Sword', + power: 0.2 + }, + levelsCompleted : [ + {id: 1, name: 'Aquamentus'}, + {id: 2, name: 'Dodongo'} + ] + }); + + ok(link.attr('sword') instanceof Sword, 'Sword got converted'); + equal(link.attr('sword').getPower(), 20, 'Got sword power!'); + equal(link.attr('sword.test'), 'Used .model', 'Data ran through Sword.model'); + ok(link.attr('levelsCompleted') instanceof Level.List, 'Got a level list'); + equal(link.attr('levelsCompleted.1.index'), 1, 'Data ran through Level.models'); +}); })();