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
JavaScript
/* 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