UNPKG

grunt-doctrine

Version:

Grunt plugin to convert doctrine xml annotations into backbone models and collections. Useful in conjunction with doctrine apigility. Includes option to scaffold an entire BBB application

1,680 lines (1,298 loc) 149 kB
/* vim: set tabstop=4 softtabstop=4 shiftwidth=4 noexpandtab: */ /*global window: false, $: false, jQuery: false, _: false, Backbone: false */ // documentation on writing tests here: http://docs.jquery.com/QUnit // example tests: https://github.com/jquery/qunit/blob/master/test/same.js // more examples: https://github.com/jquery/jquery/tree/master/test/unit // jQueryUI examples: https://github.com/jquery/jquery-ui/tree/master/tests/unit //sessionStorage.clear(); if ( !window.console ) { var names = [ 'log', 'debug', 'info', 'warn', 'error', 'assert', 'dir', 'dirxml', 'group', 'groupEnd', 'time', 'timeEnd', 'count', 'trace', 'profile', 'profileEnd' ]; window.console = {}; for ( var i = 0; i < names.length; ++i ) window.console[ names[i] ] = function() {}; } $(document).ready(function() { window.requests = []; Backbone.ajax = function( settings ) { var callbackContext = settings.context || this, dfd = new $.Deferred(); dfd = _.extend( settings, dfd ); dfd.respond = function( status, responseText ) { /** * Trigger success/error with arguments like jQuery would: * // Success/Error * if ( isSuccess ) { * deferred.resolveWith( callbackContext, [ success, statusText, jqXHR ] ); * } else { * deferred.rejectWith( callbackContext, [ jqXHR, statusText, error ] ); * } */ if ( status >= 200 && status < 300 || status === 304 ) { _.isFunction( settings.success ) && settings.success( responseText, 'success', dfd ); dfd.resolveWith( callbackContext, [ responseText, 'success', dfd ] ); } else { _.isFunction( settings.error ) && settings.error( responseText, 'error', 'Internal Server Error' ); dfd.rejectWith( callbackContext, [ dfd, 'error', 'Internal Server Error' ] ); } }; // Add the request before triggering callbacks that may get us in here again window.requests.push( dfd ); // If a `response` has been defined, execute it. // If status < 299, trigger 'success'; otherwise, trigger 'error' if ( settings.response && settings.response.status ) { dfd.respond( settings.response.status, settings.response.responseText ); } return dfd; }; Backbone.Model.prototype.url = function() { // Use the 'resource_uri' if possible var url = this.get( 'resource_uri' ); // Try to have the collection construct a url if ( !url && this.collection ) { url = this.collection.url && _.isFunction( this.collection.url ) ? this.collection.url() : this.collection.url; } // Fallback to 'urlRoot' if ( !url && this.urlRoot ) { url = this.urlRoot + this.id; } if ( !url ) { throw new Error( 'Url could not be determined!' ); } return url; }; /** * 'Zoo' */ window.Zoo = Backbone.RelationalModel.extend({ urlRoot: '/zoo/', relations: [ { type: Backbone.HasMany, key: 'animals', relatedModel: 'Animal', includeInJSON: [ 'id', 'species' ], collectionType: 'AnimalCollection', reverseRelation: { key: 'livesIn', includeInJSON: [ 'id', 'name' ] } }, { // A simple HasMany without reverse relation type: Backbone.HasMany, key: 'visitors', relatedModel: 'Visitor' } ], toString: function() { return 'Zoo (' + this.id + ')'; } }); window.Animal = Backbone.RelationalModel.extend({ urlRoot: '/animal/', relations: [ { // A simple HasOne without reverse relation type: Backbone.HasOne, key: 'favoriteFood', relatedModel: 'Food' } ], // For validation testing. Wikipedia says elephants are reported up to 12.000 kg. Any more, we must've weighted wrong ;). validate: function( attrs ) { if ( attrs.species === 'elephant' && attrs.weight && attrs.weight > 12000 ) { return "Too heavy."; } }, toString: function() { return 'Animal (' + this.id + ')'; } }); window.AnimalCollection = Backbone.Collection.extend({ model: Animal }); window.Food = Backbone.RelationalModel.extend({ urlRoot: '/food/' }); window.Visitor = Backbone.RelationalModel.extend(); /** * House/Person/Job/Company */ window.House = Backbone.RelationalModel.extend({ relations: [{ type: Backbone.HasMany, key: 'occupants', relatedModel: 'Person', reverseRelation: { key: 'livesIn', includeInJSON: false } }], toString: function() { return 'House (' + this.id + ')'; } }); window.User = Backbone.RelationalModel.extend({ urlRoot: '/user/', toString: function() { return 'User (' + this.id + ')'; } }); window.Person = Backbone.RelationalModel.extend({ relations: [ { // Create a cozy, recursive, one-to-one relationship type: Backbone.HasOne, key: 'likesALot', relatedModel: 'Person', reverseRelation: { type: Backbone.HasOne, key: 'likedALotBy' } }, { type: Backbone.HasOne, key: 'user', keyDestination: 'user_id', relatedModel: 'User', includeInJSON: Backbone.Model.prototype.idAttribute, reverseRelation: { type: Backbone.HasOne, includeInJSON: 'name', key: 'person' } }, { type: 'HasMany', key: 'jobs', relatedModel: 'Job', reverseRelation: { key: 'person' } } ], toString: function() { return 'Person (' + this.id + ')'; } }); window.PersonCollection = Backbone.Collection.extend({ model: Person }); window.Password = Backbone.RelationalModel.extend({ relations: [{ type: Backbone.HasOne, key: 'user', relatedModel: 'User', reverseRelation: { type: Backbone.HasOne, key: 'password' } }], toString: function() { return 'Password (' + this.id + ')'; } }); // A link table between 'Person' and 'Company', to achieve many-to-many relations window.Job = Backbone.RelationalModel.extend({ defaults: { 'startDate': null, 'endDate': null }, toString: function() { return 'Job (' + this.id + ')'; } }); window.Company = Backbone.RelationalModel.extend({ relations: [{ type: 'HasMany', key: 'employees', relatedModel: 'Job', reverseRelation: { key: 'company' } }, { type: 'HasOne', key: 'ceo', relatedModel: 'Person', reverseRelation: { key: 'runs' } } ], toString: function() { return 'Company (' + this.id + ')'; } }); /** * Node/NodeList */ window.Node = Backbone.RelationalModel.extend({ urlRoot: '/node/', relations: [{ type: Backbone.HasOne, key: 'parent', reverseRelation: { key: 'children' } } ], toString: function() { return 'Node (' + this.id + ')'; } }); window.NodeList = Backbone.Collection.extend({ model: Node }); /** * Customer/Address/Shop/Agent */ window.Customer = Backbone.RelationalModel.extend({ urlRoot: '/customer/', toString: function() { return 'Customer (' + this.id + ')'; } }); window.CustomerCollection = Backbone.Collection.extend({ model: Customer, initialize: function( models, options ) { options || (options = {}); this.url = options.url; } }); window.Address = Backbone.RelationalModel.extend({ urlRoot: '/address/', toString: function() { return 'Address (' + this.id + ')'; } }); window.Shop = Backbone.RelationalModel.extend({ relations: [ { type: Backbone.HasMany, key: 'customers', collectionType: 'CustomerCollection', collectionOptions: function( instance ) { return { 'url': 'shop/' + instance.id + '/customers/' }; }, relatedModel: 'Customer', autoFetch: true }, { type: Backbone.HasOne, key: 'address', relatedModel: 'Address', autoFetch: { success: function( model, response ) { response.successOK = true; }, error: function( model, response ) { response.errorOK = true; } } } ], toString: function() { return 'Shop (' + this.id + ')'; } }); window.Agent = Backbone.RelationalModel.extend({ urlRoot: '/agent/', relations: [ { type: Backbone.HasMany, key: 'customers', relatedModel: 'Customer', includeInJSON: Backbone.RelationalModel.prototype.idAttribute }, { type: Backbone.HasOne, key: 'address', relatedModel: 'Address', autoFetch: false } ], toString: function() { return 'Agent (' + this.id + ')'; } }); /** * Reset variables that are persistent across tests, specifically `window.requests` and the state of * `Backbone.Relational.store`. */ function reset() { // Reset last ajax requests window.requests = []; Backbone.Relational.store.reset(); Backbone.Relational.store.addModelScope( window ); Backbone.Relational.eventQueue = new Backbone.BlockingQueue(); } /** * Initialize a few models that are used in a large number of tests */ function initObjects() { reset(); window.person1 = new Person({ id: 'person-1', name: 'boy', likesALot: 'person-2', resource_uri: 'person-1', user: { id: 'user-1', login: 'dude', email: 'me@gmail.com', resource_uri: 'user-1' } }); window.person2 = new Person({ id: 'person-2', name: 'girl', likesALot: 'person-1', resource_uri: 'person-2' }); window.person3 = new Person({ id: 'person-3', resource_uri: 'person-3' }); window.oldCompany = new Company({ id: 'company-1', name: 'Big Corp.', ceo: { name: 'Big Boy' }, employees: [ { person: 'person-3' } ], // uses the 'Job' link table to achieve many-to-many. No 'id' specified! resource_uri: 'company-1' }); window.newCompany = new Company({ id: 'company-2', name: 'New Corp.', employees: [ { person: 'person-2' } ], resource_uri: 'company-2' }); window.ourHouse = new House({ id: 'house-1', location: 'in the middle of the street', occupants: ['person-2'], resource_uri: 'house-1' }); window.theirHouse = new House({ id: 'house-2', location: 'outside of town', occupants: [], resource_uri: 'house-2' }); } module ( "General / Backbone", { setup: reset } ); test( "Prototypes, constructors and inheritance", function() { // This stuff makes my brain hurt a bit. So, for reference: var Model = Backbone.Model.extend(), i = new Backbone.Model(), iModel = new Model(); var RelModel= Backbone.RelationalModel.extend(), iRel = new Backbone.RelationalModel(), iRelModel = new RelModel(); // Both are functions, so their `constructor` is `Function` ok( Backbone.Model.constructor === Backbone.RelationalModel.constructor ); ok( Backbone.Model !== Backbone.RelationalModel ); ok( Backbone.Model === Backbone.Model.prototype.constructor ); ok( Backbone.RelationalModel === Backbone.RelationalModel.prototype.constructor ); ok( Backbone.Model.prototype.constructor !== Backbone.RelationalModel.prototype.constructor ); ok( Model.prototype instanceof Backbone.Model ); ok( !( Model.prototype instanceof Backbone.RelationalModel ) ); ok( RelModel.prototype instanceof Backbone.Model ); ok( Backbone.RelationalModel.prototype instanceof Backbone.Model ); ok( RelModel.prototype instanceof Backbone.RelationalModel ); ok( i instanceof Backbone.Model ); ok( !( i instanceof Backbone.RelationalModel ) ); ok( iRel instanceof Backbone.Model ); ok( iRel instanceof Backbone.RelationalModel ); ok( iModel instanceof Backbone.Model ); ok( !( iModel instanceof Backbone.RelationalModel ) ); ok( iRelModel instanceof Backbone.Model ); ok( iRelModel instanceof Backbone.RelationalModel ); }); test('Collection#set', 1, function() { var a = new Backbone.Model({id: 3, label: 'a'} ), b = new Backbone.Model({id: 2, label: 'b'} ), col = new Backbone.Collection([a]); col.set([a,b], {add: true, merge: false, remove: true}); ok( col.length === 2 ); }); module( "Backbone.Semaphore", { setup: reset } ); test( "Unbounded", 10, function() { var semaphore = _.extend( {}, Backbone.Semaphore ); ok( !semaphore.isLocked(), 'Semaphore is not locked initially' ); semaphore.acquire(); ok( semaphore.isLocked(), 'Semaphore is locked after acquire' ); semaphore.acquire(); equal( semaphore._permitsUsed, 2 ,'_permitsUsed should be incremented 2 times' ); semaphore.setAvailablePermits( 4 ); equal( semaphore._permitsAvailable, 4 ,'_permitsAvailable should be 4' ); semaphore.acquire(); semaphore.acquire(); equal( semaphore._permitsUsed, 4 ,'_permitsUsed should be incremented 4 times' ); try { semaphore.acquire(); } catch( ex ) { ok( true, 'Error thrown when attempting to acquire too often' ); } semaphore.release(); equal( semaphore._permitsUsed, 3 ,'_permitsUsed should be decremented to 3' ); semaphore.release(); semaphore.release(); semaphore.release(); equal( semaphore._permitsUsed, 0 ,'_permitsUsed should be decremented to 0' ); ok( !semaphore.isLocked(), 'Semaphore is not locked when all permits are released' ); try { semaphore.release(); } catch( ex ) { ok( true, 'Error thrown when attempting to release too often' ); } }); module( "Backbone.BlockingQueue", { setup: reset } ); test( "Block", function() { var queue = new Backbone.BlockingQueue(); var count = 0; var increment = function() { count++; }; var decrement = function() { count--; }; queue.add( increment ); ok( count === 1, 'Increment executed right away' ); queue.add( decrement ); ok( count === 0, 'Decrement executed right away' ); queue.block(); queue.add( increment ); ok( queue.isLocked(), 'Queue is blocked' ); equal( count, 0, 'Increment did not execute right away' ); queue.block(); queue.block(); equal( queue._permitsUsed, 3 ,'_permitsUsed should be incremented to 3' ); queue.unblock(); queue.unblock(); queue.unblock(); equal( count, 1, 'Increment executed' ); }); module( "Backbone.Store", { setup: initObjects } ); test( "Initialized", function() { // `initObjects` instantiates models of the following types: `Person`, `Job`, `Company`, `User`, `House` and `Password`. equal( Backbone.Relational.store._collections.length, 6, "Store contains 6 collections" ); }); test( "getObjectByName", function() { equal( Backbone.Relational.store.getObjectByName( 'Backbone.RelationalModel' ), Backbone.RelationalModel ); }); test( "Add and remove from store", function() { var coll = Backbone.Relational.store.getCollection( person1 ); var length = coll.length; var person = new Person({ id: 'person-10', name: 'Remi', resource_uri: 'person-10' }); ok( coll.length === length + 1, "Collection size increased by 1" ); var request = person.destroy(); // Trigger the 'success' callback to fire the 'destroy' event request.success(); ok( coll.length === length, "Collection size decreased by 1" ); }); test( "addModelScope", function() { var models = {}; Backbone.Relational.store.addModelScope( models ); models.Book = Backbone.RelationalModel.extend({ relations: [{ type: Backbone.HasMany, key: 'pages', relatedModel: 'Page', createModels: false, reverseRelation: { key: 'book' } }] }); models.Page = Backbone.RelationalModel.extend(); var book = new models.Book(); var page = new models.Page({ book: book }); ok( book.relations.length === 1 ); ok( book.get( 'pages' ).length === 1 ); }); test( "addModelScope with submodels and namespaces", function() { var ns = {}; ns.People = {}; Backbone.Relational.store.addModelScope( ns ); ns.People.Person = Backbone.RelationalModel.extend({ subModelTypes: { 'Student': 'People.Student' }, iam: function() { return "I am an abstract person"; } }); ns.People.Student = ns.People.Person.extend({ iam: function() { return "I am a student"; } }); ns.People.PersonCollection = Backbone.Collection.extend({ model: ns.People.Person }); var people = new ns.People.PersonCollection([{name: "Bob", type: "Student"}]); ok( people.at(0).iam() === "I am a student" ); }); test( "removeModelScope", function() { var models = {}; Backbone.Relational.store.addModelScope( models ); models.Page = Backbone.RelationalModel.extend(); ok( Backbone.Relational.store.getObjectByName( 'Page' ) === models.Page ); ok( Backbone.Relational.store.getObjectByName( 'Person' ) === window.Person ); Backbone.Relational.store.removeModelScope( models ); ok( !Backbone.Relational.store.getObjectByName( 'Page' ) ); ok( Backbone.Relational.store.getObjectByName( 'Person' ) === window.Person ); Backbone.Relational.store.removeModelScope( window ); ok( !Backbone.Relational.store.getObjectByName( 'Person' ) ); }); test( "unregister", function() { var animalStoreColl = Backbone.Relational.store.getCollection( Animal ), animals = null, animal = null; // Single model animal = new Animal( { id: 'a1' } ); ok( Backbone.Relational.store.find( Animal, 'a1' ) === animal ); Backbone.Relational.store.unregister( animal ); ok( Backbone.Relational.store.find( Animal, 'a1' ) === null ); animal = new Animal( { id: 'a2' } ); ok( Backbone.Relational.store.find( Animal, 'a2' ) === animal ); animal.trigger( 'relational:unregister', animal ); ok( Backbone.Relational.store.find( Animal, 'a2' ) === null ); ok( animalStoreColl.size() === 0 ); // Collection animals = new AnimalCollection( [ { id: 'a3' }, { id: 'a4' } ] ); animal = animals.first(); ok( Backbone.Relational.store.find( Animal, 'a3' ) === animal ); ok( animalStoreColl.size() === 2 ); Backbone.Relational.store.unregister( animals ); ok( Backbone.Relational.store.find( Animal, 'a3' ) === null ); ok( animalStoreColl.size() === 0 ); // Store collection animals = new AnimalCollection( [ { id: 'a5' }, { id: 'a6' } ] ); ok( animalStoreColl.size() === 2 ); Backbone.Relational.store.unregister( animalStoreColl ); ok( animalStoreColl.size() === 0 ); // Model type animals = new AnimalCollection( [ { id: 'a7' }, { id: 'a8' } ] ); ok( animalStoreColl.size() === 2 ); Backbone.Relational.store.unregister( Animal ); ok( animalStoreColl.size() === 0 ); }); test( "`eventQueue` is unblocked again after a duplicate id error", 3, function() { var node = new Node( { id: 1 } ); ok( Backbone.Relational.eventQueue.isBlocked() === false ); try { duplicateNode = new Node( { id: 1 } ); } catch( error ) { ok( true, "Duplicate id error thrown" ); } ok( Backbone.Relational.eventQueue.isBlocked() === false ); }); test( "Don't allow setting a duplicate `id`", 4, function() { var a = new Zoo(); // This object starts with no id. var b = new Zoo( { 'id': 42 } ); // This object starts with an id of 42. equal( b.id, 42 ); try { a.set( 'id', 42 ); } catch( error ) { ok( true, "Duplicate id error thrown" ); } ok( !a.id, "a.id=" + a.id ); equal( b.id, 42 ); }); test( "Models are created from objects, can then be found, destroyed, cannot be found anymore", function() { var houseId = 'house-10'; var personId = 'person-10'; var anotherHouse = new House({ id: houseId, location: 'no country for old men', resource_uri: houseId, occupants: [{ id: personId, name: 'Remi', resource_uri: personId }] }); ok( anotherHouse.get('occupants') instanceof Backbone.Collection, "Occupants is a Collection" ); ok( anotherHouse.get('occupants').get( personId ) instanceof Person, "Occupants contains the Person with id='" + personId + "'" ); var person = Backbone.Relational.store.find( Person, personId ); ok( person, "Person with id=" + personId + " is found in the store" ); var request = person.destroy(); // Trigger the 'success' callback to fire the 'destroy' event request.success(); person = Backbone.Relational.store.find( Person, personId ); ok( !person, personId + " is not found in the store anymore" ); ok( !anotherHouse.get('occupants').get( personId ), "Occupants no longer contains the Person with id='" + personId + "'" ); request = anotherHouse.destroy(); // Trigger the 'success' callback to fire the 'destroy' event request.success(); var house = Backbone.Relational.store.find( House, houseId ); ok( !house, houseId + " is not found in the store anymore" ); }); test( "Model.collection is the first collection a Model is added to by an end-user (not its Backbone.Store collection!)", function() { var person = new Person( { id: 5, name: 'New guy' } ); var personColl = new PersonCollection(); personColl.add( person ); ok( person.collection === personColl ); }); test( "Models don't get added to the store until the get an id", function() { var storeColl = Backbone.Relational.store.getCollection( Node ), node1 = new Node( { id: 1 } ), node2 = new Node(); ok( storeColl.contains( node1 ) ); ok( !storeColl.contains( node2 ) ); node2.set( { id: 2 } ); ok( storeColl.contains( node1 ) ); }); test( "All models can be found after adding them to a Collection via 'Collection.reset'", function() { var nodes = [ { id: 1, parent: null }, { id: 2, parent: 1 }, { id: 3, parent: 4 }, { id: 4, parent: 1 } ]; var nodeList = new NodeList(); nodeList.reset( nodes ); var storeColl = Backbone.Relational.store.getCollection( Node ); equal( storeColl.length, 4, "Every Node is in Backbone.Relational.store" ); ok( Backbone.Relational.store.find( Node, 1 ) instanceof Node, "Node 1 can be found" ); ok( Backbone.Relational.store.find( Node, 2 ) instanceof Node, "Node 2 can be found" ); ok( Backbone.Relational.store.find( Node, 3 ) instanceof Node, "Node 3 can be found" ); ok( Backbone.Relational.store.find( Node, 4 ) instanceof Node, "Node 4 can be found" ); }); test( "Inheritance creates and uses a separate collection", function() { var whale = new Animal( { id: 1, species: 'whale' } ); ok( Backbone.Relational.store.find( Animal, 1 ) === whale ); var numCollections = Backbone.Relational.store._collections.length; var Mammal = Animal.extend({ urlRoot: '/mammal/' }); var lion = new Mammal( { id: 1, species: 'lion' } ); var donkey = new Mammal( { id: 2, species: 'donkey' } ); equal( Backbone.Relational.store._collections.length, numCollections + 1 ); ok( Backbone.Relational.store.find( Animal, 1 ) === whale ); ok( Backbone.Relational.store.find( Mammal, 1 ) === lion ); ok( Backbone.Relational.store.find( Mammal, 2 ) === donkey ); var Primate = Mammal.extend({ urlRoot: '/primate/' }); var gorilla = new Primate( { id: 1, species: 'gorilla' } ); equal( Backbone.Relational.store._collections.length, numCollections + 2 ); ok( Backbone.Relational.store.find( Primate, 1 ) === gorilla ); }); test( "Inheritance with `subModelTypes` uses the same collection as the model's super", function() { var Mammal = Animal.extend({ subModelTypes: { 'primate': 'Primate', 'carnivore': 'Carnivore' } }); window.Primate = Mammal.extend(); window.Carnivore = Mammal.extend(); var lion = new Carnivore( { id: 1, species: 'lion' } ); var wolf = new Carnivore( { id: 2, species: 'wolf' } ); var numCollections = Backbone.Relational.store._collections.length; var whale = new Mammal( { id: 3, species: 'whale' } ); equal( Backbone.Relational.store._collections.length, numCollections, "`_collections` should have remained the same" ); ok( Backbone.Relational.store.find( Mammal, 1 ) === lion ); ok( Backbone.Relational.store.find( Mammal, 2 ) === wolf ); ok( Backbone.Relational.store.find( Mammal, 3 ) === whale ); ok( Backbone.Relational.store.find( Carnivore, 1 ) === lion ); ok( Backbone.Relational.store.find( Carnivore, 2 ) === wolf ); ok( Backbone.Relational.store.find( Carnivore, 3 ) !== whale ); var gorilla = new Primate( { id: 4, species: 'gorilla' } ); equal( Backbone.Relational.store._collections.length, numCollections, "`_collections` should have remained the same" ); ok( Backbone.Relational.store.find( Animal, 4 ) !== gorilla ); ok( Backbone.Relational.store.find( Mammal, 4 ) === gorilla ); ok( Backbone.Relational.store.find( Primate, 4 ) === gorilla ); delete window.Primate; delete window.Carnivore; }); test( "findOrCreate does not modify attributes hash if parse is used, prior to creating new model", function () { var model = Backbone.RelationalModel.extend({ parse: function( response ) { response.id = response.id + 'something'; return response; } }); var attributes = {id: 42, foo: "bar"}; var testAttributes = {id: 42, foo: "bar"}; model.findOrCreate( attributes, { parse: true, merge: false, create: false } ); ok( _.isEqual( attributes, testAttributes ), "attributes hash should not be modified" ); }); module( "Backbone.RelationalModel", { setup: initObjects } ); test( "Return values: set returns the Model", function() { var personId = 'person-10'; var person = new Person({ id: personId, name: 'Remi', resource_uri: personId }); var result = person.set( { 'name': 'Hector' } ); ok( result === person, "Set returns the model" ); }); test( "`clear`", function() { var person = new Person( { id: 'person-10' } ); ok( person === Person.findOrCreate( 'person-10' ) ); person.clear(); ok( !person.id ); ok( !Person.findOrCreate( 'person-10' ) ); person.set( { id: 'person-10' } ); ok( person === Person.findOrCreate( 'person-10' ) ); }); test( "getRelations", function() { var relations = person1.getRelations(); equal( relations.length, 6 ); ok( _.every( relations, function( rel ) { return rel instanceof Backbone.Relation; }) ); }); test( "getRelation", function() { var userRel = person1.getRelation( 'user' ); ok( userRel instanceof Backbone.HasOne ); equal( userRel.key, 'user' ); var jobsRel = person1.getRelation( 'jobs' ); ok( jobsRel instanceof Backbone.HasMany ); equal( jobsRel.key, 'jobs' ); ok( person1.getRelation( 'nope' ) == null ); }); test( "getAsync on a HasOne relation", function() { var errorCount = 0; var person = new Person({ id: 'person-10', resource_uri: 'person-10', user: 'user-10' }); var idsToFetch = person.getIdsToFetch( 'user' ); deepEqual( idsToFetch, [ 'user-10' ] ); var request = person.getAsync( 'user', { error: function() { errorCount++; } }); ok( _.isObject( request ) && request.always && request.done && request.fail ); equal( window.requests.length, 1, "A single request has been made" ); ok( person.get( 'user' ) instanceof User ); // Triggering the 'error' callback should destroy the model window.requests[ 0 ].error(); // Trigger the 'success' callback on the `destroy` call to actually fire the 'destroy' event _.last( window.requests ).success(); ok( !person.get( 'user' ), "User has been destroyed & removed" ); equal( errorCount, 1, "The error callback executed successfully" ); var person2 = new Person({ id: 'person-11', resource_uri: 'person-11' }); request = person2.getAsync( 'user' ); equal( window.requests.length, 1, "No request was made" ); }); test( "getAsync on a HasMany relation", function() { var errorCount = 0; var zoo = new Zoo({ animals: [ { id: 'monkey-1' }, 'lion-1', 'zebra-1' ] }); var idsToFetch = zoo.getIdsToFetch( 'animals' ); deepEqual( idsToFetch, [ 'lion-1', 'zebra-1' ] ); /** * Case 1: separate requests for each model */ window.requests = []; // `getAsync` creates two placeholder models for the ids present in the relation. var request = zoo.getAsync( 'animals', { error: function() { errorCount++; } } ); ok( _.isObject( request ) && request.always && request.done && request.fail ); equal( window.requests.length, 2, "Two requests have been made (a separate one for each animal)" ); equal( zoo.get( 'animals' ).length, 3, "Three animals in the zoo" ); // Triggering the 'error' callback for one request should destroy the model window.requests[ 0 ].error(); // Trigger the 'success' callback on the `destroy` call to actually fire the 'destroy' event _.last( window.requests ).success(); equal( zoo.get( 'animals' ).length, 2, "Two animals left in the zoo" ); equal( errorCount, 1, "The error callback executed successfully" ); // Try to re-fetch; nothing left to get though, since the placeholder models got destroyed window.requests = []; request = zoo.getAsync( 'animals' ); equal( window.requests.length, 0, "No request" ); equal( zoo.get( 'animals' ).length, 2, "Two animals" ); /** * Case 2: one request per fetch (generated by the collection) */ window.requests = []; errorCount = 0; // Define a `url` function for the zoo that builds a url to fetch a set of models from their ids zoo.get( 'animals' ).url = function( models ) { var ids = _.map( models || [], function( model ) { return model instanceof Backbone.Model ? model.id : model; } ); return '/animal/' + ( ids.length ? 'set/' + ids.join( ';' ) + '/' : '' ); }; // Set two new animals to be fetched; both should be fetched in a single request. zoo.set( { animals: [ 'monkey-1', 'lion-2', 'zebra-2' ] } ); equal( zoo.get( 'animals' ).length, 1, "One animal" ); // `getAsync` should not create placeholder models in this case, since the custom `url` function // can return a url for the whole set without needing to resort to this. window.requests = []; request = zoo.getAsync( 'animals', { error: function() { errorCount++; } } ); ok( _.isObject( request ) && request.always && request.done && request.fail ); equal( window.requests.length, 1, "One request" ); equal( _.last( window.requests ).url, '/animal/set/lion-2;zebra-2/' ); equal( zoo.get('animals').length, 1, "Still only one animal in the zoo" ); // Triggering the 'error' callback (some error occured during fetching) should trigger the 'destroy' event // on both fetched models, but should NOT actually make 'delete' requests to the server! _.last( window.requests ).error(); equal( window.requests.length, 1, "An error occured when fetching, but no DELETE requests are made to the server while handling local cleanup." ); equal( zoo.get( 'animals' ).length, 1, "Both animals are destroyed" ); equal( errorCount, 1, "The error callback executed successfully" ); // Try to re-fetch; attempts to get both missing animals again window.requests = []; request = zoo.getAsync( 'animals' ); equal( window.requests.length, 1, "One request" ); equal( zoo.get( 'animals' ).length, 1, "One animal" ); // In this case, models are only created after receiving data for them window.requests[ 0 ].success( [ { id: 'lion-2' }, { id: 'zebra-2' } ] ); equal( zoo.get( 'animals' ).length, 3 ); // Re-fetch the existing models window.requests = []; request = zoo.getAsync( 'animals', { refresh: true } ); equal( window.requests.length, 1 ); equal( _.last( window.requests ).url, '/animal/set/monkey-1;lion-2;zebra-2/' ); equal( zoo.get( 'animals' ).length, 3 ); // An error while refreshing existing models shouldn't affect it window.requests[ 0 ].error(); equal( zoo.get( 'animals' ).length, 3 ); }); test( "getAsync", 8, function() { var zoo = Zoo.findOrCreate( { id: 'z-1', animals: [ 'cat-1' ] } ); zoo.on( 'add:animals', function( animal ) { console.log( 'add:animals=%o', animal ); animal.on( 'change:favoriteFood', function( model, food ) { console.log( '%s eats %s', animal.get( 'name' ), food.get( 'name' ) ); }); }); zoo.getAsync( 'animals' ).done( function( animals ) { ok( animals instanceof AnimalCollection ); ok( animals.length === 1 ); var cat = zoo.get( 'animals' ).at( 0 ); equal( cat.get( 'name' ), 'Tiger' ); cat.getAsync( 'favoriteFood' ).done( function( food ) { equal( food.get( 'name' ), 'Cheese', 'Favorite food is cheese' ); }); }); equal( zoo.get( 'animals' ).length, 1 ); equal( window.requests.length, 1 ); equal( _.last( window.requests ).url, '/animal/cat-1' ); // Declare success _.last( window.requests ).respond( 200, { id: 'cat-1', name: 'Tiger', favoriteFood: 'f-2' } ); equal( window.requests.length, 2 ); _.last( window.requests ).respond( 200, { id: 'f-2', name: 'Cheese' } ); }); test( "autoFetch a HasMany relation", function() { var shopOne = new Shop({ id: 'shop-1', customers: ['customer-1', 'customer-2'] }); equal( requests.length, 2, "Two requests to fetch the users has been made" ); requests.length = 0; var shopTwo = new Shop({ id: 'shop-2', customers: ['customer-1', 'customer-3'] }); equal( requests.length, 1, "A request to fetch a user has been made" ); //as customer-1 has already been fetched }); test( "autoFetch on a HasOne relation (with callbacks)", function() { var shopThree = new Shop({ id: 'shop-3', address: 'address-3' }); equal( requests.length, 1, "A request to fetch the address has been made" ); var res = { successOK: false, errorOK: false }; requests[0].success( res ); equal( res.successOK, true, "The success() callback has been called" ); requests.length = 0; var shopFour = new Shop({ id: 'shop-4', address: 'address-4' }); equal( requests.length, 1, "A request to fetch the address has been made" ); requests[0].error( res ); equal( res.errorOK, true, "The error() callback has been called" ); }); test( "autoFetch false by default", function() { var agentOne = new Agent({ id: 'agent-1', customers: ['customer-4', 'customer-5'] }); equal( requests.length, 0, "No requests to fetch the customers has been made as autoFetch was not defined" ); agentOne = new Agent({ id: 'agent-2', address: 'address-5' }); equal( requests.length, 0, "No requests to fetch the customers has been made as autoFetch was set to false" ); }); test( "`clone`", function() { var user = person1.get( 'user' ); // HasOne relations should stay with the original model var newPerson = person1.clone(); ok( newPerson.get( 'user' ) === null ); ok( person1.get( 'user' ) === user ); }); test( "`save` (with `wait`)", function() { var node1 = new Node({ id: '1', parent: '3', name: 'First node' } ), node2 = new Node({ id: '2', name: 'Second node' }); // Set node2's parent to node1 in a request with `wait: true` var request = node2.save( 'parent', node1, { wait: true } ), json = JSON.parse( request.data ); ok( _.isObject( json.parent ) ); equal( json.parent.id, '1' ); equal( node2.get( 'parent' ), null ); request.success(); equal( node2.get( 'parent' ), node1 ); // Save a new node as node2's parent, only specified as JSON in the call to save request = node2.save( 'parent', { id: '3', parent: '2', name: 'Third node' }, { wait: true } ); json = JSON.parse( request.data ); ok( _.isObject( json.parent ) ); equal( json.parent.id, '3' ); equal( node2.get( 'parent' ), node1 ); request.success(); var node3 = node2.get( 'parent' ); ok( node3 instanceof Node ); equal( node3.id, '3' ); // Try to reset node2's parent to node1, but fail the request request = node2.save( 'parent', node1, { wait: true } ); request.error(); equal( node2.get( 'parent' ), node3 ); // See what happens for different values of `includeInJSON`... // For `Person.user`, just the `idAttribute` should be serialized to the keyDestination `user_id` var user1 = person1.get( 'user' ); request = person1.save( 'user', null, { wait: true } ); json = JSON.parse( request.data ); console.log( request, json ); equal( person1.get( 'user' ), user1 ); request.success( json ); equal( person1.get( 'user' ), null ); request = person1.save( 'user', user1, { wait: true } ); json = JSON.parse( request.data ); equal( json.user_id, user1.id ); equal( person1.get( 'user' ), null ); request.success( json ); equal( person1.get( 'user' ), user1 ); // Save a collection with `wait: true` var zoo = new Zoo( { id: 'z1' } ), animal1 = new Animal( { id: 'a1', species: 'Goat', name: 'G' } ), coll = new Backbone.Collection( [ { id: 'a2', species: 'Rabbit', name: 'R' }, animal1 ] ); request = zoo.save( 'animals', coll, { wait: true } ); json = JSON.parse( request.data ); console.log( request, json ); ok( zoo.get( 'animals' ).length === 0 ); request.success( json ); ok( zoo.get( 'animals' ).length === 2 ); console.log( animal1 ); }); test( "`Collection.create` (with `wait`)", function() { var nodeColl = new NodeList(), nodesAdded = 0; nodeColl.on( 'add', function( model, collection, options ) { nodesAdded++; }); nodeColl.create({ id: '3', parent: '2', name: 'Third node' }, { wait: true }); ok( nodesAdded === 0 ); requests[ requests.length - 1 ].success(); ok( nodesAdded === 1 ); nodeColl.create({ id: '4', name: 'Third node' }, { wait: true }); ok( nodesAdded === 1 ); requests[ requests.length - 1 ].error(); ok( nodesAdded === 1 ); }); test( "`toJSON`: simple cases", function() { var node = new Node({ id: '1', parent: '3', name: 'First node' }); new Node({ id: '2', parent: '1', name: 'Second node' }); new Node({ id: '3', parent: '2', name: 'Third node' }); var json = node.toJSON(); ok( json.children.length === 1 ); }); test("'toJSON' should return null for relations that are set to null, even when model is not fetched", function() { var person = new Person( { user : 'u1' } ); equal( person.toJSON().user_id, 'u1' ); person.set( 'user', null ); equal( person.toJSON().user_id, null ); person = new Person( { user: new User( { id : 'u2' } ) } ); equal( person.toJSON().user_id, 'u2' ); person.set( { user: 'unfetched_user_id' } ); equal( person.toJSON().user_id, 'unfetched_user_id' ); }); test( "`toJSON` should include ids for 'unknown' or 'missing' models (if `includeInJSON` is `idAttribute`)", function() { // See GH-191 // `Zoo` shouldn't be affected; `animals.includeInJSON` is not equal to `idAttribute` var zoo = new Zoo({ id: 'z1', animals: [ 'a1', 'a2' ] }), zooJSON = zoo.toJSON(); ok( _.isArray( zooJSON.animals ) ); equal( zooJSON.animals.length, 0, "0 animals in zooJSON; it serializes an array of attributes" ); var a1 = new Animal( { id: 'a1' } ); zooJSON = zoo.toJSON(); equal( zooJSON.animals.length, 1, "1 animals in zooJSON; it serializes an array of attributes" ); // Agent -> Customer; `idAttribute` on a HasMany var agent = new Agent({ id: 'a1', customers: [ 'c1', 'c2' ] } ), agentJSON = agent.toJSON(); ok( _.isArray( agentJSON.customers ) ); equal( agentJSON.customers.length, 2, "2 customers in agentJSON; it serializes the `idAttribute`" ); var c1 = new Customer( { id: 'c1' } ); equal( agent.get( 'customers' ).length, 1, '1 customer in agent' ); agentJSON = agent.toJSON(); equal( agentJSON.customers.length, 2, "2 customers in agentJSON; `idAttribute` for 1 missing, other existing" ); //c1.destroy(); //agentJSON = agent.toJSON(); //equal( agentJSON.customers.length, 1, "1 customer in agentJSON; `idAttribute` for 1 missing, other destroyed" ); agent.set( 'customers', [ 'c1', 'c3' ] ); var c3 = new Customer( { id: 'c3' } ); agentJSON = agent.toJSON(); equal( agentJSON.customers.length, 2, "2 customers in agentJSON; 'c1' already existed, 'c3' created" ); agent.get( 'customers' ).remove( c1 ); agentJSON = agent.toJSON(); equal( agentJSON.customers.length, 1, "1 customer in agentJSON; 'c1' removed, 'c3' still in there" ); // Person -> User; `idAttribute` on a HasOne var person = new Person({ id: 'p1', user: 'u1' } ), personJSON = person.toJSON(); equal( personJSON.user_id, 'u1', "`user_id` gets set in JSON" ); var u1 = new User( { id: 'u1' } ); personJSON = person.toJSON(); ok( u1.get( 'person' ) === person ); equal( personJSON.user_id, 'u1', "`user_id` gets set in JSON" ); person.set( 'user', 'u1' ); personJSON = person.toJSON(); equal( personJSON.user_id, 'u1', "`user_id` gets set in JSON" ); u1.destroy(); personJSON = person.toJSON(); ok( !u1.get( 'person' ) ); equal( personJSON.user_id, 'u1', "`user_id` still gets set in JSON" ); }); test( "`toJSON` should include ids for unregistered models (if `includeInJSON` is `idAttribute`)", function() { // Person -> User; `idAttribute` on a HasOne var person = new Person({ id: 'p1', user: 'u1' } ), personJSON = person.toJSON(); equal( personJSON.user_id, 'u1', "`user_id` gets set in JSON even though no user obj exists" ); var u1 = new User( { id: 'u1' } ); personJSON = person.toJSON(); ok( u1.get( 'person' ) === person ); equal( personJSON.user_id, 'u1', "`user_id` gets set in JSON after matching user obj is created" ); Backbone.Relational.store.unregister(u1); personJSON = person.toJSON(); equal( personJSON.user_id, 'u1', "`user_id` gets set in JSON after user was unregistered from store" ); }); test( "`parse` gets called through `findOrCreate`", function() { var parseCalled = 0; Zoo.prototype.parse = Animal.prototype.parse = function( resp, options ) { parseCalled++; return resp; }; var zoo = Zoo.findOrCreate({ id: '1', name: 'San Diego Zoo', animals: [ { id: 'a' } ] }, { parse: true } ); var animal = zoo.get( 'animals' ).first(); ok( animal.get( 'livesIn' ) ); ok( animal.get( 'livesIn' ) instanceof Zoo ); ok( animal.get( 'livesIn' ).get( 'animals' ).get( animal ) === animal ); // `parse` gets called by `findOrCreate` directly when trying to lookup `1`, // and the parsed attributes are passed to `build` (called from `findOrCreate`) with `{ parse: false }`, // rather than having `parse` called again by the Zoo constructor. ok( parseCalled === 1, 'parse called 1 time? ' + parseCalled ); parseCalled = 0; animal = new Animal({ id: 'b' }); animal.set({ id: 'b', livesIn: { id: '2', name: 'San Diego Zoo', animals: [ 'b' ] } }, { parse: true } ); ok( animal.get( 'livesIn' ) ); ok( animal.get( 'livesIn' ) instanceof Zoo ); ok( animal.get( 'livesIn' ).get( 'animals' ).get( animal ) === animal ); ok( parseCalled === 0, 'parse called 0 times? ' + parseCalled ); // Reset `parse` methods Zoo.prototype.parse = Animal.prototype.parse = Backbone.RelationalModel.prototype.parse; }); test( "`Collection#parse` with RelationalModel simple case", function() { var Contact = Backbone.RelationalModel.extend({ parse: function( response ) { response.bar = response.foo * 2; return response; } }); var Contacts = Backbone.Collection.extend({ model: Contact, url: '/contacts', parse: function( response ) { return response.items; } }); var contacts = new Contacts(); contacts.fetch({ // fake response for testing response: { status: 200, responseText: { items: [ { foo: 1 }, { foo: 2 } ] } } }); equal( contacts.length, 2, 'Collection response was fetched properly' ); var contact = contacts.first(); ok( contact , 'Collection has a non-null item' ); ok( contact instanceof Contact, '... of the type type' ); equal( contact.get('foo'), 1, '... with correct fetched value' ); equal( contact.get('bar'), 2, '... with correct parsed value' ); }); test( "By default, `parse` should only get called on top-level objects; not for nested models and collections", function() { var companyData = { 'data': { 'id': 'company-1', 'contacts': [ { 'id': '1' }, { 'id': '2' } ] } }; var Contact = Backbone.RelationalModel.extend(); var Contacts = Backbone.Collection.extend({ model: Contact }); var Company = Backbone.RelationalModel.extend({ urlRoot: '/company/', relations: [{ type: Backbone.HasMany, key: 'contacts', relatedModel: Contact, collectionType: Contacts }] }); var parseCalled = 0; Company.prototype.parse = Contact.prototype.parse = Contacts.prototype.parse = function( resp, options ) { parseCalled++; return resp.data || resp; }; var company = new Company( companyData, { parse: true } ), contacts = company.get( 'contacts' ), contact = contacts.first(); ok( company.id === 'company-1' ); ok( contact && contact.id === '1', 'contact exists' ); ok( parseCalled === 1, 'parse called 1 time? ' + parseCalled ); // simulate what would happen if company.fetch() was called. company.fetch({ parse: true, response: { status: 200, responseText: _.clone( companyData ) } }); ok( parseCalled === 2, 'parse called 2 times? ' + parseCalled ); ok( contacts === company.get( 'contacts' ), 'contacts collection is same instance after fetch' ); equal( contacts.length, 2, '... with correct length' ); ok( contact && contact.id === '1', 'contact exists' ); ok( contact === contacts.first(), '... and same model instances' ); }); test( "constructor.findOrCreate", function() { var personColl = Backbone.Relational.store.getCollection( person1 ), origPersonCollSize = personColl.length; // Just find an existing model var person = Person.findOrCreate( person1.id ); ok( person === person1 ); ok( origPersonCollSize === personColl.length, "Existing person was found (none created)" ); // Update an existing model person = Person.findOrCreate( { id: person1.id, name: 'dude' } ); equal( person.get( 'name' ), 'dude' ); equal( person1.get( 'name' ), 'dude' ); ok( origPersonCollSize === personColl.length, "Existing person was updated (none created)" ); // Look for a non-existent person; 'options.create' is false person = Person.findOrCreate( { id: 5001 }, { create: false } ); ok( !person ); ok( origPersonCollSize === personColl.length, "No person was found (none created)" ); // Create a new model person = Person.findOrCreate( { id: 5001 } ); ok( person instanceof Person ); ok( origPersonCollSize + 1 === personColl.length, "No person was found (1 created)" ); // Find when options.merge is false person = Person.findOrCreate( { id: person1.id, name: 'phil' }, { merge: false } ); equal( person.get( 'name' ), 'dude' ); equal( person1.get( 'name' ), 'dude' ); }); test( "constructor.find", function() { var personColl = Backbone.Relational.store.getCollection( person1 ), origPersonCollSize = personColl.length; // Look for a non-existent person person = Person.find( { id: 5001 } ); ok( !person ); }); test( "change events in relation can use changedAttributes properly", function() { var scope = {}; Backbone.Relational.store.addModelScope( scope ); scope.PetAnimal = Backbone.RelationalModel.extend({ subModelTypes: { 'cat': 'Cat', 'dog': 'Dog' } }); scope.Dog = scope.PetAnimal.extend(); scope.Cat = scope.PetAnimal.extend(); scope.PetOwner = Backbone.RelationalModel.extend({ relations: [{ type: Backbone.HasMany, key: 'pets', relatedModel: scope.PetAnimal, reverseRelation: { key: 'owner' } }] }); var owner = new scope.PetOwner( { id: 'owner-2354' } ); var animal = new scope.Dog( { type: 'dog', id: '238902', color: 'blue' } ); equal( animal.get('color'), 'blue', 'animal starts out blue' ); var changes = 0, changedAttrs = null; animal.on('change', function(model, options) { changes++; changedAttrs = model.changedAttributes(); }); animal.set( { color: 'green' } ); equal( changes, 1, 'change event gets called after animal.set' ); equal( changedAttrs.color, 'green', '... with correct properties in "changedAttributes"' ); owner.set(owner.parse({ id: 'owner-2354', pets: [ { id: '238902', type: 'dog', color: 'red' } ] })); equal( animal.get('color'), 'red', 'color gets updated properly' ); equal( changes, 2, 'change event gets called after owner.set' ); equal( changedAttrs.color, 'red', '... with correct properties in "changedAttributes"' ); }); test( 'change events should not fire on new items in Collection#set', function() { var modelChangeEvents = 0, collectionChangeEvents = 0; var Animal2 = Animal.extend({ initialize: function(options) { this.on( 'all', function( name, event ) { //console.log( 'Animal2: %o', arguments ); if ( name.indexOf( 'change' ) === 0 ) { modelChangeEvents++; } }); } }); var AnimalCollection2 = AnimalCollection.extend({ model: Animal2, initialize: function(options) { this.on( 'all', function( name, event ) { //console.log( 'AnimalCollection2: %o', arguments ); if ( name.indexOf('change') === 0 ) { collectionChangeEvents++; } }); } }); var zoo = new Zoo( { id: 'zoo-1' } ); var coll = new AnimalCollection2(); coll.set( [{ id: 'animal-1', livesIn: 'zoo-1' }] ); equal( collectionChangeEvents, 0, 'no change event should be triggered on the collection' ); modelChangeEvents = collectionChang