├── CNAME ├── .npmignore ├── .gitignore ├── static ├── fonts │ ├── PT_Sans.woff │ ├── Museo_Slab_500.eot │ ├── Museo_Slab_500.otf │ └── Museo_Slab_500.woff ├── js │ └── prism.js └── css │ └── style.css ├── bower.json ├── .travis.yml ├── .editorconfig ├── README.md ├── test ├── setup │ ├── setup.js │ ├── data.js │ ├── environment.js │ └── objects.js ├── blocking-queue.js ├── semaphore.js ├── backbone.js ├── has-one.js ├── benchmarks.js ├── events.js ├── store.js ├── collection.js ├── has-many.js ├── reverse-relations.js ├── relation.js └── relational-model.js ├── karma.conf.js ├── LICENSE.txt ├── package.json ├── CONTRIBUTING.md └── .eslintrc.json /CNAME: -------------------------------------------------------------------------------- 1 | backbonerelational.org -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | node_modules 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /static/fonts/PT_Sans.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PaulUithol/Backbone-relational/HEAD/static/fonts/PT_Sans.woff -------------------------------------------------------------------------------- /static/fonts/Museo_Slab_500.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PaulUithol/Backbone-relational/HEAD/static/fonts/Museo_Slab_500.eot -------------------------------------------------------------------------------- /static/fonts/Museo_Slab_500.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PaulUithol/Backbone-relational/HEAD/static/fonts/Museo_Slab_500.otf -------------------------------------------------------------------------------- /static/fonts/Museo_Slab_500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PaulUithol/Backbone-relational/HEAD/static/fonts/Museo_Slab_500.woff -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backbone-relational", 3 | "license": "MIT", 4 | "main": "backbone-relational.js", 5 | "dependencies": { 6 | "underscore": ">=1.7.0", 7 | "backbone": ">=1.3.3" 8 | }, 9 | "ignore": ["static", "test", ".html"] 10 | } 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | before_install: 5 | - npm install -g npm 6 | - npm install -g karma-cli@1 7 | script: 8 | - npm test 9 | - karma start --single-run --browsers PhantomJS --lodash 10 | sudo: false 11 | cache: 12 | directories: 13 | - node_modules 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [*.js] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [*.json] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [*.md] 18 | indent_style = space 19 | indent_size = 4 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Backbone-relational 2 | 3 | Backbone-relational provides one-to-one, one-to-many and many-to-one relations between models for [Backbone](https://github.com/documentcloud/backbone). 4 | 5 | Documentation: http://backbonerelational.org 6 | 7 | Backbone-relational is released under the [MIT license](https://github.com/PaulUithol/Backbone-relational/blob/master/LICENSE.txt). 8 | Contributors: https://github.com/PaulUithol/Backbone-relational/contributors 9 | -------------------------------------------------------------------------------- /test/setup/setup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Reset variables that are persistent across tests, specifically `window.requests` and the state of 3 | * `Backbone.Relational.store`. 4 | */ 5 | exports.reset = function reset() { 6 | // Reset last ajax requests 7 | window.requests = []; 8 | 9 | Backbone.Relational.store.reset(); 10 | Backbone.Relational.store.addModelScope( window ); 11 | Backbone.Relational.eventQueue = new Backbone.Relational.BlockingQueue(); 12 | } 13 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | var browserifyOptions = { 3 | debug: true 4 | }; 5 | if (config.lodash) browserifyOptions.transform = [ 6 | ['aliasify', { 7 | aliases: { 8 | underscore: 'lodash', 9 | }, 10 | }], 11 | ]; 12 | config.set({ 13 | frameworks: [ 14 | 'browserify', 15 | 'qunit' 16 | ], 17 | plugins: [ 18 | 'karma-browserify', 19 | 'karma-phantomjs-launcher', 20 | 'karma-chrome-launcher', 21 | 'karma-qunit' 22 | ], 23 | 24 | files: [ 25 | 'test/setup/environment.js', 26 | 'test/*.js' 27 | ], 28 | 29 | preprocessors: { 30 | 'test/**/*.js': [ 'browserify' ] 31 | }, 32 | 33 | browserify: browserifyOptions, 34 | 35 | autoWatch: false, 36 | port: 9877, 37 | colors: true, 38 | singleRun: true, 39 | logLevel: config.LOG_INFO 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /test/blocking-queue.js: -------------------------------------------------------------------------------- 1 | QUnit.module( "Backbone.Relational.BlockingQueue", { setup: require('./setup/setup').reset } ); 2 | 3 | QUnit.test( "Block", function() { 4 | var queue = new Backbone.Relational.BlockingQueue(); 5 | var count = 0; 6 | var increment = function() { count++; }; 7 | var decrement = function() { count--; }; 8 | 9 | queue.add( increment ); 10 | ok( count === 1, 'Increment executed right away' ); 11 | 12 | queue.add( decrement ); 13 | ok( count === 0, 'Decrement executed right away' ); 14 | 15 | queue.block(); 16 | queue.add( increment ); 17 | 18 | ok( queue.isLocked(), 'Queue is blocked' ); 19 | equal( count, 0, 'Increment did not execute right away' ); 20 | 21 | queue.block(); 22 | queue.block(); 23 | 24 | equal( queue._permitsUsed, 3 ,'_permitsUsed should be incremented to 3' ); 25 | 26 | queue.unblock(); 27 | queue.unblock(); 28 | queue.unblock(); 29 | 30 | equal( count, 1, 'Increment executed' ); 31 | }); 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011-2014 Paul Uithol, http://progressivecompany.nl/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backbone-relational", 3 | "main": "backbone-relational.js", 4 | "description": "Get and set relations (one-to-one, one-to-many, many-to-one) for Backbone models", 5 | "homepage": "http://backbonerelational.org", 6 | "keywords": [ 7 | "backbone", 8 | "relation", 9 | "nested", 10 | "model" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/PaulUithol/Backbone-relational.git" 15 | }, 16 | "scripts": { 17 | "test": "karma start --single-run --browsers PhantomJS" 18 | }, 19 | "author": "Paul Uithol ", 20 | "contributors": "Listed at ", 21 | "peerDependencies": { 22 | "backbone": "^1.2.3" 23 | }, 24 | "license": "MIT", 25 | "version": "0.10.0", 26 | "devDependencies": { 27 | "aliasify": "^2.1.0", 28 | "backbone": "^1.3.3", 29 | "browserify": "^13.0.0", 30 | "jquery": "^2.2.1", 31 | "karma": "^0.13.22", 32 | "karma-browserify": "^5.0.2", 33 | "karma-chrome-launcher": "^0.2.2", 34 | "karma-phantomjs-launcher": "^1.0.0", 35 | "karma-qunit": "^0.1.9", 36 | "lodash": "^4.17.11", 37 | "phantomjs-prebuilt": "^2.1.5", 38 | "qunitjs": "^1.22.0", 39 | "semver": "^5.1.0", 40 | "underscore": ">=1.7.0", 41 | "watchify": "^3.7.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/semaphore.js: -------------------------------------------------------------------------------- 1 | QUnit.module( "Backbone.Relational.Semaphore ", { setup: require('./setup/setup').reset } ); 2 | 3 | QUnit.test( "Unbounded", 10, function() { 4 | var semaphore = _.extend( {}, Backbone.Relational.Semaphore ); 5 | ok( !semaphore.isLocked(), 'Semaphore is not locked initially' ); 6 | semaphore.acquire(); 7 | ok( semaphore.isLocked(), 'Semaphore is locked after acquire' ); 8 | semaphore.acquire(); 9 | equal( semaphore._permitsUsed, 2 ,'_permitsUsed should be incremented 2 times' ); 10 | 11 | semaphore.setAvailablePermits( 4 ); 12 | equal( semaphore._permitsAvailable, 4 ,'_permitsAvailable should be 4' ); 13 | 14 | semaphore.acquire(); 15 | semaphore.acquire(); 16 | equal( semaphore._permitsUsed, 4 ,'_permitsUsed should be incremented 4 times' ); 17 | 18 | try { 19 | semaphore.acquire(); 20 | } 21 | catch( ex ) { 22 | ok( true, 'Error thrown when attempting to acquire too often' ); 23 | } 24 | 25 | semaphore.release(); 26 | equal( semaphore._permitsUsed, 3 ,'_permitsUsed should be decremented to 3' ); 27 | 28 | semaphore.release(); 29 | semaphore.release(); 30 | semaphore.release(); 31 | equal( semaphore._permitsUsed, 0 ,'_permitsUsed should be decremented to 0' ); 32 | ok( !semaphore.isLocked(), 'Semaphore is not locked when all permits are released' ); 33 | 34 | try { 35 | semaphore.release(); 36 | } 37 | catch( ex ) { 38 | ok( true, 'Error thrown when attempting to release too often' ); 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /test/setup/data.js: -------------------------------------------------------------------------------- 1 | var Objects = require('./objects'); 2 | 3 | /** 4 | * Initialize a few models that are used in a large number of tests 5 | */ 6 | module.exports = function initObjects() { 7 | require('./setup').reset(); 8 | 9 | window.person1 = new Person({ 10 | id: 'person-1', 11 | name: 'boy', 12 | likesALot: 'person-2', 13 | resource_uri: 'person-1', 14 | user: { id: 'user-1', login: 'dude', email: 'me@gmail.com', resource_uri: 'user-1' } 15 | }); 16 | 17 | window.person2 = new Person({ 18 | id: 'person-2', 19 | name: 'girl', 20 | likesALot: 'person-1', 21 | resource_uri: 'person-2' 22 | }); 23 | 24 | window.person3 = new Person({ 25 | id: 'person-3', 26 | resource_uri: 'person-3' 27 | }); 28 | 29 | window.oldCompany = new Company({ 30 | id: 'company-1', 31 | name: 'Big Corp.', 32 | ceo: { 33 | name: 'Big Boy' 34 | }, 35 | employees: [ { person: 'person-3' } ], // uses the 'Job' link table to achieve many-to-many. No 'id' specified! 36 | resource_uri: 'company-1' 37 | }); 38 | 39 | window.newCompany = new Company({ 40 | id: 'company-2', 41 | name: 'New Corp.', 42 | employees: [ { person: 'person-2' } ], 43 | resource_uri: 'company-2' 44 | }); 45 | 46 | window.ourHouse = new House({ 47 | id: 'house-1', 48 | location: 'in the middle of the street', 49 | occupants: ['person-2'], 50 | resource_uri: 'house-1' 51 | }); 52 | 53 | window.theirHouse = new House({ 54 | id: 'house-2', 55 | location: 'outside of town', 56 | occupants: [], 57 | resource_uri: 'house-2' 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /test/backbone.js: -------------------------------------------------------------------------------- 1 | QUnit.module( "General / Backbone", { setup: require('./setup/setup').reset } ); 2 | 3 | QUnit.test( "Prototypes, constructors and inheritance", function() { 4 | // This stuff makes my brain hurt a bit. So, for reference: 5 | var Model = Backbone.Model.extend(), 6 | i = new Backbone.Model(), 7 | iModel = new Model(); 8 | 9 | var RelModel= Backbone.Relational.Model.extend(), 10 | iRel = new Backbone.Relational.Model(), 11 | iRelModel = new RelModel(); 12 | 13 | // Both are functions, so their `constructor` is `Function` 14 | ok( Backbone.Model.constructor === Backbone.Relational.Model.constructor ); 15 | 16 | ok( Backbone.Model !== Backbone.Relational.Model ); 17 | ok( Backbone.Model === Backbone.Model.prototype.constructor ); 18 | ok( Backbone.Relational.Model === Backbone.Relational.Model.prototype.constructor ); 19 | ok( Backbone.Model.prototype.constructor !== Backbone.Relational.Model.prototype.constructor ); 20 | 21 | ok( Model.prototype instanceof Backbone.Model ); 22 | ok( !( Model.prototype instanceof Backbone.Relational.Model ) ); 23 | ok( RelModel.prototype instanceof Backbone.Model ); 24 | ok( Backbone.Relational.Model.prototype instanceof Backbone.Model ); 25 | ok( RelModel.prototype instanceof Backbone.Relational.Model ); 26 | 27 | ok( i instanceof Backbone.Model ); 28 | ok( !( i instanceof Backbone.Relational.Model ) ); 29 | ok( iRel instanceof Backbone.Model ); 30 | ok( iRel instanceof Backbone.Relational.Model ); 31 | 32 | ok( iModel instanceof Backbone.Model ); 33 | ok( !( iModel instanceof Backbone.Relational.Model ) ); 34 | ok( iRelModel instanceof Backbone.Model ); 35 | ok( iRelModel instanceof Backbone.Relational.Model ); 36 | }); 37 | 38 | QUnit.test('Collection#set', 1, function() { 39 | var a = new Backbone.Model({id: 3, label: 'a'} ), 40 | b = new Backbone.Model({id: 2, label: 'b'} ), 41 | col = new Backbone.Relational.Collection([a]); 42 | 43 | col.set([a,b], {add: true, merge: false, remove: true}); 44 | ok( col.length === 2 ); 45 | }); 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Reporting a Bug/Feature/Enhancement 2 | In order to help out with bug reports please make sure you include the following: 3 | - Backbone Relational Version 4 | - Backbone Version 5 | - Underscore Version 6 | 7 | It is very important to provide a meaningful description with your bug reports/feature requests. A good format for these descriptions will include the following: 8 | 9 | 1. The problem you are facing (in as much detail as is necessary to describe the problem to someone who doesn't know anything about the system you're building) 10 | - It is *extremely* helpful to make a failing test case or JSFiddle example that covers your use case. Below you can find a template JSFiddle with the Jasmine test suite and Backbone relational `0.10.0` setup (feel free to fork this!): 11 | - http://jsfiddle.net/4223kp5e/1/ 12 | 2. A summary of the proposed solution 13 | 3. A description of how this solution solves the problem, in more detail than item #2 14 | 4. Any additional discussion on possible problems this might introduce, questions that you have related to the changes, etc. 15 | 16 | # Submitting a Pull Request 17 | Before you submit your Pull Request ensure the following things are true for your branch: 18 | 1. Your additions match the same coding style defined in our linter rules and EditorConfig rules 19 | - How to setup [EditorConfig](http://editorconfig.org/#download) 20 | - How to setup [ESLint](http://eslint.org/docs/user-guide/integrations) 21 | 2. Your changes are branched off of `master` 22 | 3. You have added a test case for your changes or updated an existing test case 23 | 4. All test cases are passing, both with Underscore and Lodash 24 | 25 | # Running Unit Tests 26 | You can run tests using PhantomJS (headless) or a web browser (Chrome) 27 | - To run headless tests: 28 | 1. Run `npm test` 29 | - To run tests in a browser: 30 | 1. Run `karma start --single-run` 31 | 2. Open Chrome and connect to the url it outputs 32 | 33 | You can test with Lodash instead of Underscore by passing the `--lodash` option to `karma start` or `yarn test`. 34 | -------------------------------------------------------------------------------- /test/setup/environment.js: -------------------------------------------------------------------------------- 1 | var _ = window._ = require('underscore'); 2 | var $ = window.$ = require('jquery'); 3 | var Backbone = window.Backbone = require('backbone'); 4 | 5 | //sessionStorage.clear(); 6 | if ( ! window.console ) { 7 | var names = [ 'log', 'debug', 'info', 'warn', 'error', 'assert', 'dir', 'dirxml', 8 | 'group', 'groupEnd', 'time', 'timeEnd', 'count', 'trace', 'profile', 'profileEnd' ]; 9 | window.console = {}; 10 | for ( var i = 0; i < names.length; ++i ) 11 | window.console[ names[i] ] = function() {}; 12 | } 13 | 14 | window.requests = []; 15 | 16 | Backbone.ajax = function( settings ) { 17 | var callbackContext = settings.context || this, 18 | dfd = new $.Deferred(); 19 | 20 | dfd = _.extend( settings, dfd ); 21 | 22 | dfd.respond = function( status, responseText ) { 23 | /** 24 | * Trigger success/error with arguments like jQuery would: 25 | * // Success/Error 26 | * if ( isSuccess ) { 27 | * deferred.resolveWith( callbackContext, [ success, statusText, jqXHR ] ); 28 | * } else { 29 | * deferred.rejectWith( callbackContext, [ jqXHR, statusText, error ] ); 30 | * } 31 | */ 32 | if ( status >= 200 && status < 300 || status === 304 ) { 33 | _.isFunction( settings.success ) && settings.success( responseText, 'success', dfd ); 34 | dfd.resolveWith( callbackContext, [ responseText, 'success', dfd ] ); 35 | } 36 | else { 37 | _.isFunction( settings.error ) && settings.error( responseText, 'error', 'Internal Server Error' ); 38 | dfd.rejectWith( callbackContext, [ dfd, 'error', 'Internal Server Error' ] ); 39 | } 40 | }; 41 | 42 | // Add the request before triggering callbacks that may get us in here again 43 | window.requests.push( dfd ); 44 | 45 | // If a `response` has been defined, execute it. 46 | // If status < 299, trigger 'success'; otherwise, trigger 'error' 47 | if ( settings.response && settings.response.status ) { 48 | dfd.respond( settings.response.status, settings.response.responseText ); 49 | } 50 | 51 | return dfd; 52 | }; 53 | 54 | Backbone.Model.prototype.url = function() { 55 | // Use the 'resource_uri' if possible 56 | var url = this.get( 'resource_uri' ); 57 | 58 | // Try to have the collection construct a url 59 | if ( !url && this.collection ) { 60 | url = this.collection.url && _.isFunction( this.collection.url ) ? this.collection.url() : this.collection.url; 61 | } 62 | 63 | // Fallback to 'urlRoot' 64 | if ( !url && this.urlRoot ) { 65 | url = this.urlRoot + this.id; 66 | } 67 | 68 | if ( !url ) { 69 | throw new Error( 'Url could not be determined!' ); 70 | } 71 | 72 | return url; 73 | }; 74 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "amd": true 6 | }, 7 | "globals": { 8 | "attachEvent": true, 9 | "detachEvent": true 10 | }, 11 | "rules": { 12 | "array-bracket-spacing": [2], 13 | "block-scoped-var": 2, 14 | "brace-style": [1, "1tbs", {"allowSingleLine": true}], 15 | "camelcase": 2, 16 | "comma-dangle": [2, "never"], 17 | "comma-spacing": 2, 18 | "computed-property-spacing": [2, "never"], 19 | "dot-notation": [2, { "allowKeywords": false }], 20 | "eol-last": 2, 21 | "eqeqeq": [2, "smart"], 22 | "indent": [2, 2, {"SwitchCase": 1}], 23 | "key-spacing": 1, 24 | "linebreak-style": 2, 25 | "max-depth": [1, 4], 26 | "max-params": [1, 5], 27 | "new-cap": [2, {"newIsCapExceptions": ["model"]}], 28 | "no-alert": 2, 29 | "no-caller": 2, 30 | "no-catch-shadow": 2, 31 | "no-console": 2, 32 | "no-debugger": 2, 33 | "no-delete-var": 2, 34 | "no-div-regex": 1, 35 | "no-dupe-args": 2, 36 | "no-dupe-keys": 2, 37 | "no-duplicate-case": 2, 38 | "no-else-return": 1, 39 | "no-empty-character-class": 2, 40 | "no-labels": 2, 41 | "no-eval": 2, 42 | "no-ex-assign": 2, 43 | "no-extend-native": 2, 44 | "no-extra-boolean-cast": 2, 45 | "no-extra-parens": 1, 46 | "no-extra-semi": 2, 47 | "no-fallthrough": 2, 48 | "no-floating-decimal": 2, 49 | "no-func-assign": 2, 50 | "no-implied-eval": 2, 51 | "no-inner-declarations": 2, 52 | "no-irregular-whitespace": 2, 53 | "no-label-var": 2, 54 | "no-lone-blocks": 2, 55 | "no-lonely-if": 2, 56 | "no-multi-str": 2, 57 | "no-native-reassign": 2, 58 | "no-negated-in-lhs": 1, 59 | "no-new-object": 2, 60 | "no-new-wrappers": 2, 61 | "no-obj-calls": 2, 62 | "no-octal": 2, 63 | "no-octal-escape": 2, 64 | "no-proto": 2, 65 | "no-redeclare": 2, 66 | "no-shadow": 2, 67 | "no-spaced-func": 2, 68 | "no-throw-literal": 2, 69 | "no-trailing-spaces": 2, 70 | "no-undef": 2, 71 | "no-undef-init": 2, 72 | "no-undefined": 2, 73 | "no-unneeded-ternary": 2, 74 | "no-unreachable": 2, 75 | "no-unused-expressions": [2, {"allowTernary": true, "allowShortCircuit": true}], 76 | "no-with": 2, 77 | "object-curly-spacing": [2, "never"], 78 | "quote-props": [1, "consistent-as-needed"], 79 | "quotes": [2, "single", "avoid-escape"], 80 | "radix": 2, 81 | "semi": 2, 82 | "keyword-spacing": 2, 83 | "space-before-function-paren": [2, {"anonymous": "never", "named": "never"}], 84 | "space-infix-ops": 2, 85 | "space-unary-ops": [2, { "words": true, "nonwords": false }], 86 | "use-isnan": 2, 87 | "valid-typeof": 2, 88 | "wrap-iife": [2, "inside"] 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /test/has-one.js: -------------------------------------------------------------------------------- 1 | QUnit.module( "Backbone.Relational.HasOne", { setup: require('./setup/data') } ); 2 | 3 | QUnit.test( "HasOne relations on Person are set up properly", function() { 4 | ok( person1.get('likesALot') === person2 ); 5 | equal( person1.get('user').id, 'user-1', "The id of 'person1's user is 'user-1'" ); 6 | ok( person2.get('likesALot') === person1 ); 7 | }); 8 | 9 | QUnit.test( "Reverse HasOne relations on Person are set up properly", function() { 10 | ok( person1.get( 'likedALotBy' ) === person2 ); 11 | ok( person1.get( 'user' ).get( 'person' ) === person1, "The person belonging to 'person1's user is 'person1'" ); 12 | ok( person2.get( 'likedALotBy' ) === person1 ); 13 | }); 14 | 15 | QUnit.test( "'set' triggers 'change' and 'update', on a HasOne relation, for a Model with multiple relations", 9, function() { 16 | // triggers initialization of the reverse relation from User to Password 17 | var password = new Password( { plaintext: 'asdf' } ); 18 | 19 | person1.on( 'change', function( model, options ) { 20 | ok( model.get( 'user' ) instanceof User, "In 'change', model.user is an instance of User" ); 21 | equal( model.previous( 'user' ).get( 'login' ), oldLogin, "previousAttributes is available on 'change'" ); 22 | }); 23 | 24 | person1.on( 'change:user', function( model, options ) { 25 | ok( model.get( 'user' ) instanceof User, "In 'change:user', model.user is an instance of User" ); 26 | equal( model.previous( 'user' ).get( 'login' ), oldLogin, "previousAttributes is available on 'change'" ); 27 | }); 28 | 29 | person1.on( 'change:user', function( model, attr, options ) { 30 | ok( model.get( 'user' ) instanceof User, "In 'change:user', model.user is an instance of User" ); 31 | ok( attr.get( 'person' ) === person1, "The user's 'person' is 'person1'" ); 32 | ok( attr.get( 'password' ) instanceof Password, "The user's password attribute is a model of type Password"); 33 | equal( attr.get( 'password' ).get( 'plaintext' ), 'qwerty', "The user's password is ''qwerty'" ); 34 | }); 35 | 36 | var user = { login: 'me@hotmail.com', password: { plaintext: 'qwerty' } }; 37 | var oldLogin = person1.get( 'user' ).get( 'login' ); 38 | 39 | // Triggers assertions for 'change' and 'change:user' 40 | person1.set( { user: user } ); 41 | 42 | user = person1.get( 'user' ).on( 'change:password', function( model, attr, options ) { 43 | equal( attr.get( 'plaintext' ), 'asdf', "The user's password is ''qwerty'" ); 44 | }); 45 | 46 | // Triggers assertions for 'change:user' 47 | user.set( { password: password } ); 48 | }); 49 | 50 | QUnit.test( "'set' doesn't triggers 'change' and 'change:' when passed `silent: true`", 2, function() { 51 | person1.on( 'change', function( model, options ) { 52 | ok( false, "'change' should not get triggered" ); 53 | }); 54 | 55 | person1.on( 'change:user', function( model, attr, options ) { 56 | ok( false, "'change:user' should not get triggered" ); 57 | }); 58 | 59 | person1.on( 'change:user', function( model, attr, options ) { 60 | ok( false, "'change:user' should not get triggered" ); 61 | }); 62 | 63 | ok( person1.get( 'user' ) instanceof User, "person1 has a 'user'" ); 64 | 65 | var user = new User({ login: 'me@hotmail.com', password: { plaintext: 'qwerty' } }); 66 | person1.set( 'user', user, { silent: true } ); 67 | 68 | equal( person1.get( 'user' ), user ); 69 | }); 70 | 71 | QUnit.test( "'unset' triggers 'change' and 'change:'", 4, function() { 72 | person1.on( 'change', function( model, options ) { 73 | equal( model.get('user'), null, "model.user is unset" ); 74 | }); 75 | 76 | person1.on( 'change:user', function( model, attr, options ) { 77 | equal( attr, null, "new value of attr (user) is null" ); 78 | }); 79 | 80 | ok( person1.get( 'user' ) instanceof User, "person1 has a 'user'" ); 81 | 82 | var user = person1.get( 'user' ); 83 | person1.unset( 'user' ); 84 | 85 | equal( user.get( 'person' ), null, "person1 is not set on 'user' anymore" ); 86 | }); 87 | 88 | QUnit.test( "'clear' triggers 'change' and 'change:'", 4, function() { 89 | person1.on( 'change', function( model, options ) { 90 | equal( model.get('user'), null, "model.user is unset" ); 91 | }); 92 | 93 | person1.on( 'change:user', function( model, attr, options ) { 94 | equal( attr, null, "new value of attr (user) is null" ); 95 | }); 96 | 97 | ok( person1.get( 'user' ) instanceof User, "person1 has a 'user'" ); 98 | 99 | var user = person1.get( 'user' ); 100 | person1.clear(); 101 | 102 | equal( user.get( 'person' ), null, "person1 is not set on 'user' anymore" ); 103 | }); 104 | -------------------------------------------------------------------------------- /static/js/prism.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prism: Lightweight, robust, elegant syntax highlighting 3 | * MIT license http://www.opensource.org/licenses/mit-license.php/ 4 | * @author Lea Verou http://lea.verou.me 5 | */(function(){var e=/\blang(?:uage)?-(?!\*)(\w+)\b/i,t=self.Prism={util:{type:function(e){return Object.prototype.toString.call(e).match(/\[object (\w+)\]/)[1]},clone:function(e){var n=t.util.type(e);switch(n){case"Object":var r={};for(var i in e)e.hasOwnProperty(i)&&(r[i]=t.util.clone(e[i]));return r;case"Array":return e.slice()}return e}},languages:{extend:function(e,n){var r=t.util.clone(t.languages[e]);for(var i in n)r[i]=n[i];return r},insertBefore:function(e,n,r,i){i=i||t.languages;var s=i[e],o={};for(var u in s)if(s.hasOwnProperty(u)){if(u==n)for(var a in r)r.hasOwnProperty(a)&&(o[a]=r[a]);o[u]=s[u]}return i[e]=o},DFS:function(e,n){for(var r in e){n.call(e,r,e[r]);t.util.type(e)==="Object"&&t.languages.DFS(e[r],n)}}},highlightAll:function(e,n){var r=document.querySelectorAll('code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code');for(var i=0,s;s=r[i++];)t.highlightElement(s,e===!0,n)},highlightElement:function(r,i,s){var o,u,a=r;while(a&&!e.test(a.className))a=a.parentNode;if(a){o=(a.className.match(e)||[,""])[1];u=t.languages[o]}if(!u)return;r.className=r.className.replace(e,"").replace(/\s+/g," ")+" language-"+o;a=r.parentNode;/pre/i.test(a.nodeName)&&(a.className=a.className.replace(e,"").replace(/\s+/g," ")+" language-"+o);var f=r.textContent;if(!f)return;f=f.replace(/&/g,"&").replace(//g,">").replace(/\u00a0/g," ");var l={element:r,language:o,grammar:u,code:f};t.hooks.run("before-highlight",l);if(i&&self.Worker){var c=new Worker(t.filename);c.onmessage=function(e){l.highlightedCode=n.stringify(JSON.parse(e.data));l.element.innerHTML=l.highlightedCode;s&&s.call(l.element);t.hooks.run("after-highlight",l)};c.postMessage(JSON.stringify({language:l.language,code:l.code}))}else{l.highlightedCode=t.highlight(l.code,l.grammar);l.element.innerHTML=l.highlightedCode;s&&s.call(r);t.hooks.run("after-highlight",l)}},highlight:function(e,r){return n.stringify(t.tokenize(e,r))},tokenize:function(e,n){var r=t.Token,i=[e],s=n.rest;if(s){for(var o in s)n[o]=s[o];delete n.rest}e:for(var o in n){if(!n.hasOwnProperty(o)||!n[o])continue;var u=n[o],a=u.inside,f=!!u.lookbehind||0;u=u.pattern||u;for(var l=0;le.length)break e;if(c instanceof r)continue;u.lastIndex=0;var h=u.exec(c);if(h){f&&(f=h[1].length);var p=h.index-1+f,h=h[0].slice(f),d=h.length,v=p+d,m=c.slice(0,p+1),g=c.slice(v+1),y=[l,1];m&&y.push(m);var b=new r(o,a?t.tokenize(h,a):h);y.push(b);g&&y.push(g);Array.prototype.splice.apply(i,y)}}}return i},hooks:{all:{},add:function(e,n){var r=t.hooks.all;r[e]=r[e]||[];r[e].push(n)},run:function(e,n){var r=t.hooks.all[e];if(!r||!r.length)return;for(var i=0,s;s=r[i++];)s(n)}}},n=t.Token=function(e,t){this.type=e;this.content=t};n.stringify=function(e){if(typeof e=="string")return e;if(Object.prototype.toString.call(e)=="[object Array]"){for(var r=0;r"+i.content+""};if(!self.document){self.addEventListener("message",function(e){var n=JSON.parse(e.data),r=n.language,i=n.code;self.postMessage(JSON.stringify(t.tokenize(i,t.languages[r])));self.close()},!1);return}var r=document.getElementsByTagName("script");r=r[r.length-1];if(r){t.filename=r.src;document.addEventListener&&!r.hasAttribute("data-manual")&&document.addEventListener("DOMContentLoaded",t.highlightAll)}})();; 6 | Prism.languages.markup={comment:/<!--[\w\W]*?--(>|>)/g,prolog:/<\?.+?\?>/,doctype:/<!DOCTYPE.+?>/,cdata:/<!\[CDATA\[[\w\W]+?]]>/i,tag:{pattern:/<\/?[\w:-]+\s*(?:\s+[\w:-]+(?:=(?:("|')(\\?[\w\W])*?\1|\w+))?\s*)*\/?>/gi,inside:{tag:{pattern:/^<\/?[\w:-]+/i,inside:{punctuation:/^<\/?/,namespace:/^[\w-]+?:/}},"attr-value":{pattern:/=(?:('|")[\w\W]*?(\1)|[^\s>]+)/gi,inside:{punctuation:/=|>|"/g}},punctuation:/\/?>/g,"attr-name":{pattern:/[\w:-]+/g,inside:{namespace:/^[\w-]+?:/}}}},entity:/&#?[\da-z]{1,8};/gi};Prism.hooks.add("wrap",function(e){e.type==="entity"&&(e.attributes.title=e.content.replace(/&/,"&"))});; 7 | Prism.languages.clike={comment:{pattern:/(^|[^\\])(\/\*[\w\W]*?\*\/|\/\/.*?(\r?\n|$))/g,lookbehind:!0},string:/("|')(\\?.)*?\1/g,keyword:/\b(if|else|while|do|for|return|in|instanceof|function|new|try|catch|finally|null|break|continue)\b/g,"boolean":/\b(true|false)\b/g,number:/\b-?(0x)?\d*\.?[\da-f]+\b/g,operator:/[-+]{1,2}|!|=?<|=?>|={1,2}|(&){1,2}|\|?\||\?|\*|\//g,ignore:/&(lt|gt|amp);/gi,punctuation:/[{}[\];(),.:]/g};; 8 | Prism.languages.javascript=Prism.languages.extend("clike",{keyword:/\b(var|let|if|else|while|do|for|return|in|instanceof|function|new|with|typeof|try|catch|finally|null|break|continue)\b/g,number:/\b(-?(0x)?\d*\.?[\da-f]+|NaN|-?Infinity)\b/g});Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:/(^|[^/])\/(?!\/)(\[.+?]|\\.|[^/\r\n])+\/[gim]{0,3}(?=\s*($|[\r\n,.;})]))/g,lookbehind:!0}});Prism.languages.markup&&Prism.languages.insertBefore("markup","tag",{script:{pattern:/(<|<)script[\w\W]*?(>|>)[\w\W]*?(<|<)\/script(>|>)/ig,inside:{tag:{pattern:/(<|<)script[\w\W]*?(>|>)|(<|<)\/script(>|>)/ig,inside:Prism.languages.markup.tag.inside},rest:Prism.languages.javascript}}});; 9 | -------------------------------------------------------------------------------- /test/benchmarks.js: -------------------------------------------------------------------------------- 1 | QUnit.module( "Performance", { setup: require('./setup/setup').reset } ); 2 | 3 | QUnit.test( "Creation and destruction", 0, function() { 4 | var registerCount = 0, 5 | unregisterCount = 0, 6 | register = Backbone.Relational.Store.prototype.register, 7 | unregister = Backbone.Relational.Store.prototype.unregister; 8 | 9 | Backbone.Relational.Store.prototype.register = function( model ) { 10 | registerCount++; 11 | return register.apply( this, arguments ); 12 | }; 13 | Backbone.Relational.Store.prototype.unregister = function( model, coll, options ) { 14 | unregisterCount++; 15 | return unregister.apply( this, arguments ); 16 | }; 17 | 18 | var addHasManyCount = 0, 19 | addHasOneCount = 0, 20 | tryAddRelatedHasMany = Backbone.Relational.HasMany.prototype.tryAddRelated, 21 | tryAddRelatedHasOne = Backbone.Relational.HasOne.prototype.tryAddRelated; 22 | 23 | Backbone.Relational.Store.prototype.tryAddRelated = function( model, coll, options ) { 24 | addHasManyCount++; 25 | return tryAddRelatedHasMany.apply( this, arguments ); 26 | }; 27 | Backbone.Relational.HasOne.prototype.tryAddRelated = function( model, coll, options ) { 28 | addHasOneCount++; 29 | return tryAddRelatedHasOne.apply( this, arguments ); 30 | }; 31 | 32 | var removeHasManyCount = 0, 33 | removeHasOneCount = 0, 34 | removeRelatedHasMany = Backbone.Relational.HasMany.prototype.removeRelated, 35 | removeRelatedHasOne= Backbone.Relational.HasOne.prototype.removeRelated; 36 | 37 | Backbone.Relational.HasMany.prototype.removeRelated = function( model, coll, options ) { 38 | removeHasManyCount++; 39 | return removeRelatedHasMany.apply( this, arguments ); 40 | }; 41 | Backbone.Relational.HasOne.prototype.removeRelated = function( model, coll, options ) { 42 | removeHasOneCount++; 43 | return removeRelatedHasOne.apply( this, arguments ); 44 | }; 45 | 46 | var Child = Backbone.Relational.Model.extend({ 47 | url: '/child/', 48 | 49 | toString: function() { 50 | return this.id; 51 | } 52 | }); 53 | 54 | var Parent = Backbone.Relational.Model.extend({ 55 | relations: [{ 56 | type: Backbone.Relational.HasMany, 57 | key: 'children', 58 | relatedModel: Child, 59 | reverseRelation: { 60 | key: 'parent' 61 | } 62 | }], 63 | 64 | toString: function() { 65 | return this.get( 'name' ); 66 | } 67 | }); 68 | 69 | var Parents = Backbone.Relational.Collection.extend({ 70 | model: Parent 71 | }); 72 | 73 | 74 | 75 | // bootstrap data 76 | var data = []; 77 | for ( var i = 1; i <= 300; i++ ) { 78 | data.push({ 79 | name: 'parent-' + i, 80 | children: [ 81 | {id: 'p-' + i + '-c1', name: 'child-1'}, 82 | {id: 'p-' + i + '-c2', name: 'child-2'}, 83 | {id: 'p-' + i + '-c3', name: 'child-3'} 84 | ] 85 | }); 86 | } 87 | 88 | 89 | /** 90 | * Test 2 91 | */ 92 | Backbone.Relational.store.reset(); 93 | addHasManyCount = addHasOneCount = 0; 94 | console.log('loading test 2...'); 95 | var start = new Date(); 96 | 97 | var preparedData = _.map( data, function( item ) { 98 | item = _.clone( item ); 99 | item.children = item.children.map( function( child ) { 100 | return new Child( child ); 101 | }); 102 | return item; 103 | }); 104 | 105 | var parents = new Parents(); 106 | 107 | parents.on('reset', function () { 108 | var secs = (new Date() - start) / 1000; 109 | console.log( 'data loaded in %s, addHasManyCount=%o, addHasOneCount=%o', secs, addHasManyCount, addHasOneCount ); 110 | }); 111 | parents.reset( preparedData ); 112 | 113 | //_.invoke( _.clone( parents.models ), 'destroy' ); 114 | 115 | 116 | /** 117 | * Test 1 118 | */ 119 | Backbone.Relational.store.reset(); 120 | addHasManyCount = addHasOneCount = 0; 121 | console.log('loading test 1...'); 122 | var start = new Date(); 123 | 124 | var parents = new Parents(); 125 | 126 | parents.on('reset', function () { 127 | var secs = (new Date() - start) / 1000; 128 | console.log( 'data loaded in %s, addHasManyCount=%o, addHasOneCount=%o', secs, addHasManyCount, addHasOneCount ); 129 | }); 130 | parents.reset( data ); 131 | 132 | //_.invoke( _.clone( parents.models ), 'destroy' ); 133 | 134 | 135 | /** 136 | * Test 2 (again) 137 | */ 138 | Backbone.Relational.store.reset(); 139 | addHasManyCount = addHasOneCount = removeHasManyCount = removeHasOneCount = 0; 140 | console.log('loading test 2...'); 141 | var start = new Date(); 142 | 143 | var parents = new Parents(); 144 | parents.on('reset', function () { 145 | var secs = (new Date() - start) / 1000; 146 | console.log( 'data loaded in %s, addHasManyCount=%o, addHasOneCount=%o', secs, addHasManyCount, addHasOneCount ); 147 | }); 148 | parents.reset( preparedData ); 149 | 150 | 151 | start = new Date(); 152 | 153 | parents.each( function( parent ) { 154 | var children = _.clone( parent.get( 'children' ).models ); 155 | _.each( children, function( child ) { 156 | child.destroy(); 157 | }); 158 | }); 159 | 160 | var secs = (new Date() - start) / 1000; 161 | console.log( 'data loaded in %s, removeHasManyCount=%o, removeHasOneCount=%o', secs, removeHasManyCount, removeHasOneCount ); 162 | 163 | //_.invoke( _.clone( parents.models ), 'destroy' ); 164 | 165 | /** 166 | * Test 1 (again) 167 | */ 168 | Backbone.Relational.store.reset(); 169 | addHasManyCount = addHasOneCount = removeHasManyCount = removeHasOneCount = 0; 170 | console.log('loading test 1...'); 171 | var start = new Date(); 172 | 173 | var parents = new Parents(); 174 | parents.on('reset', function () { 175 | var secs = (new Date() - start) / 1000; 176 | console.log( 'data loaded in %s, addHasManyCount=%o, addHasOneCount=%o', secs, addHasManyCount, addHasOneCount ); 177 | }); 178 | parents.reset(data); 179 | 180 | start = new Date(); 181 | 182 | parents.remove( parents.models ); 183 | 184 | var secs = (new Date() - start) / 1000; 185 | console.log( 'data removed in %s, removeHasManyCount=%o, removeHasOneCount=%o', secs, removeHasManyCount, removeHasOneCount ); 186 | 187 | console.log( 'registerCount=%o, unregisterCount=%o', registerCount, unregisterCount ); 188 | }); 189 | -------------------------------------------------------------------------------- /test/setup/objects.js: -------------------------------------------------------------------------------- 1 | var _ = window._ = require('underscore'); 2 | var $ = window.$ = require('jquery'); 3 | var Backbone = window.Backbone = require('backbone'); 4 | Backbone.Relational = require('../../backbone-relational'); 5 | 6 | /** 7 | * 'Zoo' 8 | */ 9 | 10 | exports.Zoo = window.Zoo = Backbone.Relational.Model.extend({ 11 | urlRoot: '/zoo/', 12 | 13 | relations: [ 14 | { 15 | type: Backbone.Relational.HasMany, 16 | key: 'animals', 17 | relatedModel: 'Animal', 18 | includeInJSON: [ 'id', 'species' ], 19 | collectionType: 'AnimalCollection', 20 | reverseRelation: { 21 | key: 'livesIn', 22 | includeInJSON: [ 'id', 'name' ] 23 | } 24 | }, 25 | { // A simple HasMany without reverse relation 26 | type: Backbone.Relational.HasMany, 27 | key: 'visitors', 28 | relatedModel: 'Visitor' 29 | } 30 | ], 31 | 32 | toString: function() { 33 | return 'Zoo (' + this.id + ')'; 34 | } 35 | }); 36 | 37 | 38 | exports.Animal = window.Animal = Backbone.Relational.Model.extend({ 39 | urlRoot: '/animal/', 40 | 41 | relations: [ 42 | { // A simple HasOne without reverse relation 43 | type: Backbone.Relational.HasOne, 44 | key: 'favoriteFood', 45 | relatedModel: 'Food' 46 | } 47 | ], 48 | 49 | // For validation testing. Wikipedia says elephants are reported up to 12.000 kg. Any more, we must've weighted wrong ;). 50 | validate: function( attrs ) { 51 | if ( attrs.species === 'elephant' && attrs.weight && attrs.weight > 12000 ) { 52 | return "Too heavy."; 53 | } 54 | }, 55 | 56 | toString: function() { 57 | return 'Animal (' + this.id + ')'; 58 | } 59 | }); 60 | 61 | exports.AnimalCollection = window.AnimalCollection = Backbone.Relational.Collection.extend({ 62 | model: Animal 63 | }); 64 | 65 | exports.Food = window.Food = Backbone.Relational.Model.extend({ 66 | urlRoot: '/food/' 67 | }); 68 | 69 | exports.Visitor = window.Visitor = Backbone.Relational.Model.extend(); 70 | 71 | 72 | /** 73 | * House/Person/Job/Company 74 | */ 75 | 76 | exports.House = window.House = Backbone.Relational.Model.extend({ 77 | relations: [{ 78 | type: Backbone.Relational.HasMany, 79 | key: 'occupants', 80 | relatedModel: 'Person', 81 | reverseRelation: { 82 | key: 'livesIn', 83 | includeInJSON: false 84 | } 85 | }], 86 | 87 | toString: function() { 88 | return 'House (' + this.id + ')'; 89 | } 90 | }); 91 | 92 | exports.User = window.User = Backbone.Relational.Model.extend({ 93 | urlRoot: '/user/', 94 | 95 | toString: function() { 96 | return 'User (' + this.id + ')'; 97 | } 98 | }); 99 | 100 | exports.Person = window.Person = Backbone.Relational.Model.extend({ 101 | relations: [ 102 | { 103 | // Create a cozy, recursive, one-to-one relationship 104 | type: Backbone.Relational.HasOne, 105 | key: 'likesALot', 106 | relatedModel: 'Person', 107 | reverseRelation: { 108 | type: Backbone.Relational.HasOne, 109 | key: 'likedALotBy' 110 | } 111 | }, 112 | { 113 | type: Backbone.Relational.HasOne, 114 | key: 'user', 115 | keyDestination: 'user_id', 116 | relatedModel: 'User', 117 | includeInJSON: Backbone.Model.prototype.idAttribute, 118 | reverseRelation: { 119 | type: Backbone.Relational.HasOne, 120 | includeInJSON: 'name', 121 | key: 'person' 122 | } 123 | }, 124 | { 125 | type: 'HasMany', 126 | key: 'jobs', 127 | relatedModel: 'Job', 128 | reverseRelation: { 129 | key: 'person' 130 | } 131 | } 132 | ], 133 | 134 | toString: function() { 135 | return 'Person (' + this.id + ')'; 136 | } 137 | }); 138 | 139 | exports.PersonCollection = window.PersonCollection = Backbone.Relational.Collection.extend({ 140 | model: Person 141 | }); 142 | 143 | exports.Password = window.Password = Backbone.Relational.Model.extend({ 144 | relations: [{ 145 | type: Backbone.Relational.HasOne, 146 | key: 'user', 147 | relatedModel: 'User', 148 | reverseRelation: { 149 | type: Backbone.Relational.HasOne, 150 | key: 'password' 151 | } 152 | }], 153 | 154 | toString: function() { 155 | return 'Password (' + this.id + ')'; 156 | } 157 | }); 158 | 159 | // A link table between 'Person' and 'Company', to achieve many-to-many relations 160 | exports.Job = window.Job = Backbone.Relational.Model.extend({ 161 | defaults: { 162 | 'startDate': null, 163 | 'endDate': null 164 | }, 165 | 166 | toString: function() { 167 | return 'Job (' + this.id + ')'; 168 | } 169 | }); 170 | 171 | exports.Company = window.Company = Backbone.Relational.Model.extend({ 172 | relations: [{ 173 | type: 'HasMany', 174 | key: 'employees', 175 | relatedModel: 'Job', 176 | reverseRelation: { 177 | key: 'company' 178 | } 179 | }, 180 | { 181 | type: 'HasOne', 182 | key: 'ceo', 183 | relatedModel: 'Person', 184 | reverseRelation: { 185 | key: 'runs' 186 | } 187 | } 188 | ], 189 | 190 | toString: function() { 191 | return 'Company (' + this.id + ')'; 192 | } 193 | }); 194 | 195 | 196 | /** 197 | * Node/NodeList 198 | */ 199 | exports.Node = window.Node = Backbone.Relational.Model.extend({ 200 | urlRoot: '/node/', 201 | 202 | relations: [{ 203 | type: Backbone.Relational.HasOne, 204 | key: 'parent', 205 | reverseRelation: { 206 | key: 'children' 207 | } 208 | } 209 | ], 210 | 211 | toString: function() { 212 | return 'Node (' + this.id + ')'; 213 | } 214 | }); 215 | 216 | exports.NodeList = window.NodeList = Backbone.Relational.Collection.extend({ 217 | model: Node 218 | }); 219 | 220 | 221 | /** 222 | * Customer/Address/Shop/Agent 223 | */ 224 | 225 | exports.Customer = window.Customer = Backbone.Relational.Model.extend({ 226 | urlRoot: '/customer/', 227 | 228 | toString: function() { 229 | return 'Customer (' + this.id + ')'; 230 | } 231 | }); 232 | 233 | exports.CustomerCollection = window.CustomerCollection = Backbone.Relational.Collection.extend({ 234 | model: Customer, 235 | 236 | initialize: function( models, options ) { 237 | options || (options = {}); 238 | this.url = options.url; 239 | } 240 | }); 241 | 242 | exports.Address = window.Address = Backbone.Relational.Model.extend({ 243 | urlRoot: '/address/', 244 | 245 | toString: function() { 246 | return 'Address (' + this.id + ')'; 247 | } 248 | }); 249 | 250 | exports.Shop = window.Shop = Backbone.Relational.Model.extend({ 251 | relations: [ 252 | { 253 | type: Backbone.Relational.HasMany, 254 | key: 'customers', 255 | collectionType: 'CustomerCollection', 256 | collectionOptions: function( instance ) { 257 | return { 'url': 'shop/' + instance.id + '/customers/' }; 258 | }, 259 | relatedModel: 'Customer', 260 | autoFetch: true 261 | }, 262 | { 263 | type: Backbone.Relational.HasOne, 264 | key: 'address', 265 | relatedModel: 'Address', 266 | autoFetch: { 267 | success: function( model, response ) { 268 | response.successOK = true; 269 | }, 270 | error: function( model, response ) { 271 | response.errorOK = true; 272 | } 273 | } 274 | } 275 | ], 276 | 277 | toString: function() { 278 | return 'Shop (' + this.id + ')'; 279 | } 280 | }); 281 | 282 | exports.Agent = window.Agent = Backbone.Relational.Model.extend({ 283 | urlRoot: '/agent/', 284 | 285 | relations: [ 286 | { 287 | type: Backbone.Relational.HasMany, 288 | key: 'customers', 289 | relatedModel: 'Customer', 290 | includeInJSON: Backbone.Relational.Model.prototype.idAttribute 291 | }, 292 | { 293 | type: Backbone.Relational.HasOne, 294 | key: 'address', 295 | relatedModel: 'Address', 296 | autoFetch: false 297 | } 298 | ], 299 | 300 | toString: function() { 301 | return 'Agent (' + this.id + ')'; 302 | } 303 | }); 304 | -------------------------------------------------------------------------------- /test/events.js: -------------------------------------------------------------------------------- 1 | QUnit.module( "Events", { setup: require('./setup/setup').reset } ); 2 | 3 | QUnit.test( "`add:`, `remove:` and `change:` events", function() { 4 | var zoo = new Zoo(), 5 | animal = new Animal(); 6 | 7 | var addAnimalEventsTriggered = 0, 8 | removeAnimalEventsTriggered = 0, 9 | changeEventsTriggered = 0, 10 | changeLiveInEventsTriggered = 0; 11 | 12 | zoo.on( 'add:animals', function( model, coll ) { 13 | //console.log( 'add:animals; args=%o', arguments ); 14 | addAnimalEventsTriggered++; 15 | }) 16 | .on( 'remove:animals', function( model, coll ) { 17 | //console.log( 'remove:animals; args=%o', arguments ); 18 | removeAnimalEventsTriggered++; 19 | }); 20 | 21 | animal 22 | .on( 'change', function( model, coll ) { 23 | console.log( 'change; args=%o', arguments ); 24 | changeEventsTriggered++; 25 | }) 26 | .on( 'change:livesIn', function( model, coll ) { 27 | //console.log( 'change:livesIn; args=%o', arguments ); 28 | changeLiveInEventsTriggered++; 29 | }); 30 | 31 | // Directly triggering an event on a model should always fire 32 | addAnimalEventsTriggered = removeAnimalEventsTriggered = changeEventsTriggered = changeLiveInEventsTriggered = 0; 33 | 34 | animal.trigger( 'change', this.model ); 35 | ok( changeEventsTriggered === 1 ); 36 | ok( changeLiveInEventsTriggered === 0 ); 37 | 38 | addAnimalEventsTriggered = removeAnimalEventsTriggered = changeEventsTriggered = changeLiveInEventsTriggered = 0; 39 | 40 | // Should trigger `change:livesIn` and `add:animals` 41 | animal.set( 'livesIn', zoo ); 42 | 43 | zoo.set( 'id', 'z1' ); 44 | animal.set( 'id', 'a1' ); 45 | 46 | ok( addAnimalEventsTriggered === 1 ); 47 | ok( removeAnimalEventsTriggered === 0 ); 48 | ok( changeEventsTriggered === 2 ); 49 | ok( changeLiveInEventsTriggered === 1 ); 50 | console.log( changeEventsTriggered ); 51 | 52 | // Doing this shouldn't trigger any `add`/`remove`/`update` events 53 | zoo.set( 'animals', [ 'a1' ] ); 54 | 55 | ok( addAnimalEventsTriggered === 1 ); 56 | ok( removeAnimalEventsTriggered === 0 ); 57 | ok( changeEventsTriggered === 2 ); 58 | ok( changeLiveInEventsTriggered === 1 ); 59 | 60 | // Doesn't cause an actual state change 61 | animal.set( 'livesIn', 'z1' ); 62 | 63 | ok( addAnimalEventsTriggered === 1 ); 64 | ok( removeAnimalEventsTriggered === 0 ); 65 | ok( changeEventsTriggered === 2 ); 66 | ok( changeLiveInEventsTriggered === 1 ); 67 | 68 | // Should trigger a `remove` on zoo and an `update` on animal 69 | animal.set( 'livesIn', { id: 'z2' } ); 70 | 71 | ok( addAnimalEventsTriggered === 1 ); 72 | ok( removeAnimalEventsTriggered === 1 ); 73 | ok( changeEventsTriggered === 3 ); 74 | ok( changeLiveInEventsTriggered === 2 ); 75 | }); 76 | 77 | QUnit.test( "`reset` events", function() { 78 | var initialize = AnimalCollection.prototype.initialize; 79 | var resetEvents = 0, 80 | addEvents = 0, 81 | removeEvents = 0; 82 | 83 | AnimalCollection.prototype.initialize = function() { 84 | this 85 | .on( 'add', function() { 86 | addEvents++; 87 | }) 88 | .on( 'reset', function() { 89 | resetEvents++; 90 | }) 91 | .on( 'remove', function() { 92 | removeEvents++; 93 | }); 94 | }; 95 | 96 | var zoo = new Zoo(); 97 | 98 | // No events triggered when initializing a HasMany 99 | ok( zoo.get( 'animals' ) instanceof AnimalCollection ); 100 | ok( resetEvents === 0, "No `reset` event fired" ); 101 | ok( addEvents === 0 ); 102 | ok( removeEvents === 0 ); 103 | 104 | zoo.set( 'animals', { id: 1 } ); 105 | 106 | ok( addEvents === 1 ); 107 | ok( zoo.get( 'animals' ).length === 1, "animals.length === 1" ); 108 | 109 | zoo.get( 'animals' ).reset(); 110 | 111 | ok( resetEvents === 1, "`reset` event fired" ); 112 | ok( zoo.get( 'animals' ).length === 0, "animals.length === 0" ); 113 | 114 | AnimalCollection.prototype.initialize = initialize; 115 | }); 116 | 117 | QUnit.test( "Firing of `change` and `change:` events", function() { 118 | var data = { 119 | id: 1, 120 | animals: [] 121 | }; 122 | 123 | var zoo = new Zoo( data ); 124 | 125 | var change = 0; 126 | zoo.on( 'change', function() { 127 | change++; 128 | }); 129 | 130 | var changeAnimals = 0; 131 | zoo.on( 'change:animals', function() { 132 | changeAnimals++; 133 | }); 134 | 135 | var animalChange = 0; 136 | zoo.get( 'animals' ).on( 'change', function() { 137 | animalChange++; 138 | }); 139 | 140 | // Set the same data 141 | zoo.set( data ); 142 | 143 | ok( change === 0, 'no change event should fire' ); 144 | ok( changeAnimals === 0, 'no change:animals event should fire' ); 145 | ok( animalChange === 0, 'no animals:change event should fire' ); 146 | 147 | // Add an `animal` 148 | change = changeAnimals = animalChange = 0; 149 | zoo.set( { animals: [ { id: 'a1' } ] } ); 150 | 151 | ok( change === 1, 'change event should fire' ); 152 | ok( changeAnimals === 1, 'change:animals event should fire' ); 153 | ok( animalChange === 1, 'animals:change event should fire' ); 154 | 155 | // Change an animal 156 | change = changeAnimals = animalChange = 0; 157 | zoo.set( { animals: [ { id: 'a1', name: 'a1' } ] } ); 158 | 159 | ok( change === 0, 'no change event should fire' ); 160 | ok( changeAnimals === 0, 'no change:animals event should fire' ); 161 | ok( animalChange === 1, 'animals:change event should fire' ); 162 | 163 | // Only change the `zoo` itself 164 | change = changeAnimals = animalChange = 0; 165 | zoo.set( { name: 'Artis' } ); 166 | 167 | ok( change === 1, 'change event should fire' ); 168 | ok( changeAnimals === 0, 'no change:animals event should fire' ); 169 | ok( animalChange === 0, 'no animals:change event should fire' ); 170 | 171 | // Replace an `animal` 172 | change = changeAnimals = animalChange = 0; 173 | zoo.set( { animals: [ { id: 'a2' } ] } ); 174 | 175 | ok( change === 1, 'change event should fire' ); 176 | ok( changeAnimals === 1, 'change:animals event should fire' ); 177 | ok( animalChange === 1, 'animals:change event should fire' ); 178 | 179 | // Remove an `animal` 180 | change = changeAnimals = animalChange = 0; 181 | zoo.set( { animals: [] } ); 182 | 183 | ok( change === 1, 'change event should fire' ); 184 | ok( changeAnimals === 1, 'change:animals event should fire' ); 185 | ok( animalChange === 0, 'no animals:change event should fire' ); 186 | 187 | // Operate directly on the HasMany collection 188 | var animals = zoo.get( 'animals' ), 189 | a1 = Animal.findOrCreate( 'a1', { create: false } ), 190 | a2 = Animal.findOrCreate( 'a2', { create: false } ); 191 | 192 | ok( a1 instanceof Animal ); 193 | ok( a2 instanceof Animal ); 194 | 195 | // Add an animal 196 | change = changeAnimals = animalChange = 0; 197 | animals.add( 'a2' ); 198 | 199 | ok( change === 0, 'change event not should fire' ); 200 | ok( changeAnimals === 0, 'no change:animals event should fire' ); 201 | ok( animalChange === 0, 'no animals:change event should fire' ); 202 | 203 | // Update an animal directly 204 | change = changeAnimals = animalChange = 0; 205 | a2.set( 'name', 'a2' ); 206 | 207 | ok( change === 0, 'no change event should fire' ); 208 | ok( changeAnimals === 0, 'no change:animals event should fire' ); 209 | ok( animalChange === 1, 'animals:change event should fire' ); 210 | 211 | // Remove an animal directly 212 | change = changeAnimals = animalChange = 0; 213 | animals.remove( 'a2' ); 214 | 215 | ok( change === 0, 'no change event should fire' ); 216 | ok( changeAnimals === 0, 'no change:animals event should fire' ); 217 | ok( animalChange === 0, 'no animals:change event should fire' ); 218 | }); 219 | 220 | QUnit.test( "Does not trigger add / remove events for existing models on bulk assignment", function() { 221 | var house = new House({ 222 | id: 'house-100', 223 | location: 'in the middle of the street', 224 | occupants: [ { id : 'person-5', jobs: [ { id : 'job-22' } ] }, { id : 'person-6' } ] 225 | }); 226 | 227 | var eventsTriggered = 0; 228 | 229 | house 230 | .on( 'add:occupants', function(model) { 231 | ok( false, model.id + " should not be added" ); 232 | eventsTriggered++; 233 | }) 234 | .on( 'remove:occupants', function(model) { 235 | ok( false, model.id + " should not be removed" ); 236 | eventsTriggered++; 237 | }); 238 | 239 | house.get( 'occupants' ).at( 0 ).on( 'add:jobs', function( model ) { 240 | ok( false, model.id + " should not be added" ); 241 | eventsTriggered++; 242 | }); 243 | 244 | house.set( house.toJSON() ); 245 | 246 | ok( eventsTriggered === 0, "No add / remove events were triggered" ); 247 | }); 248 | 249 | QUnit.test( "triggers appropriate add / remove / change events on bulk assignment", function() { 250 | var house = new House({ 251 | id: 'house-100', 252 | location: 'in the middle of the street', 253 | occupants: [ { id : 'person-5', nickname : 'Jane' }, { id : 'person-6' }, { id : 'person-8', nickname : 'Jon' } ] 254 | }); 255 | 256 | var addEventsTriggered = 0, 257 | removeEventsTriggered = 0, 258 | changeEventsTriggered = 0; 259 | 260 | house.on( 'add:occupants', function( model ) { 261 | ok( model.id === 'person-7', "Only person-7 should be added: " + model.id + " being added" ); 262 | addEventsTriggered++; 263 | }) 264 | .on( 'remove:occupants', function( model ) { 265 | ok( model.id === 'person-6', "Only person-6 should be removed: " + model.id + " being removed" ); 266 | removeEventsTriggered++; 267 | }); 268 | 269 | house.get( 'occupants' ).on( 'change:nickname', function( model ) { 270 | ok( model.id === 'person-8', "Only person-8 should have it's nickname updated: " + model.id + " nickname updated" ); 271 | changeEventsTriggered++; 272 | }); 273 | 274 | house.set( { occupants : [ { id : 'person-5', nickname : 'Jane'}, { id : 'person-7' }, { id : 'person-8', nickname : 'Phil' } ] } ); 275 | 276 | ok( addEventsTriggered === 1, "Exactly one add event was triggered (triggered " + addEventsTriggered + " events)" ); 277 | ok( removeEventsTriggered === 1, "Exactly one remove event was triggered (triggered " + removeEventsTriggered + " events)" ); 278 | ok( changeEventsTriggered === 1, "Exactly one change event was triggered (triggered " + changeEventsTriggered + " events)" ); 279 | }); 280 | 281 | QUnit.test( "triggers appropriate change events even when callbacks have triggered set with an unchanging value", function() { 282 | var house = new House({ 283 | id: 'house-100', 284 | location: 'in the middle of the street' 285 | }); 286 | 287 | var changeEventsTriggered = 0; 288 | 289 | house 290 | .on('change:location', function() { 291 | house.set({location: 'somewhere else'}); 292 | }) 293 | .on( 'change', function () { 294 | changeEventsTriggered++; 295 | }); 296 | 297 | house.set( { location: 'somewhere else' } ); 298 | 299 | ok( changeEventsTriggered === 1, 'one change triggered for `house`' ); 300 | 301 | var person = new Person({ 302 | id: 1 303 | }); 304 | 305 | changeEventsTriggered = 0; 306 | 307 | person 308 | .on('change:livesIn', function() { 309 | //console.log( arguments ); 310 | house.set({livesIn: house}); 311 | }) 312 | .on( 'change', function () { 313 | //console.log( arguments ); 314 | changeEventsTriggered++; 315 | }); 316 | 317 | person.set({livesIn: house}); 318 | 319 | ok( changeEventsTriggered === 2, 'one change each triggered for `house` and `person`' ); 320 | }); 321 | -------------------------------------------------------------------------------- /test/store.js: -------------------------------------------------------------------------------- 1 | QUnit.module( "Backbone.Relational.Store", { setup: require('./setup/data') } ); 2 | 3 | QUnit.test( "Initialized", function() { 4 | // `initObjects` instantiates models of the following types: `Person`, `Job`, `Company`, `User`, `House` and `Password`. 5 | equal( Backbone.Relational.store._collections.length, 6, "Store contains 6 collections" ); 6 | }); 7 | 8 | QUnit.test( "getObjectByName", function() { 9 | equal( Backbone.Relational.store.getObjectByName( 'Backbone.Relational.Model' ), Backbone.Relational.Model ); 10 | }); 11 | 12 | QUnit.test( "Add and remove from store", function() { 13 | var coll = Backbone.Relational.store.getCollection( person1 ); 14 | var length = coll.length; 15 | 16 | var person = new Person({ 17 | id: 'person-10', 18 | name: 'Remi', 19 | resource_uri: 'person-10' 20 | }); 21 | 22 | ok( coll.length === length + 1, "Collection size increased by 1" ); 23 | 24 | var request = person.destroy(); 25 | // Trigger the 'success' callback to fire the 'destroy' event 26 | request.success(); 27 | 28 | ok( coll.length === length, "Collection size decreased by 1" ); 29 | }); 30 | 31 | QUnit.test( "addModelScope", function() { 32 | var models = {}; 33 | Backbone.Relational.store.addModelScope( models ); 34 | 35 | models.Book = Backbone.Relational.Model.extend({ 36 | relations: [{ 37 | type: Backbone.Relational.HasMany, 38 | key: 'pages', 39 | relatedModel: 'Page', 40 | createModels: false, 41 | reverseRelation: { 42 | key: 'book' 43 | } 44 | }] 45 | }); 46 | models.Page = Backbone.Relational.Model.extend(); 47 | 48 | var book = new models.Book(); 49 | var page = new models.Page({ book: book }); 50 | 51 | ok( book.relations.length === 1 ); 52 | ok( book.get( 'pages' ).length === 1 ); 53 | }); 54 | 55 | QUnit.test( "addModelScope with submodels and namespaces", function() { 56 | var ns = {}; 57 | ns.People = {}; 58 | Backbone.Relational.store.addModelScope( ns ); 59 | 60 | ns.People.Person = Backbone.Relational.Model.extend({ 61 | subModelTypes: { 62 | 'Student': 'People.Student' 63 | }, 64 | iam: function() { return "I am an abstract person"; } 65 | }); 66 | 67 | ns.People.Student = ns.People.Person.extend({ 68 | iam: function() { return "I am a student"; } 69 | }); 70 | 71 | ns.People.PersonCollection = Backbone.Relational.Collection.extend({ 72 | model: ns.People.Person 73 | }); 74 | 75 | var people = new ns.People.PersonCollection([{name: "Bob", type: "Student"}]); 76 | 77 | ok( people.at(0).iam() === "I am a student" ); 78 | }); 79 | 80 | QUnit.test( "removeModelScope", function() { 81 | var models = {}; 82 | Backbone.Relational.store.addModelScope( models ); 83 | 84 | models.Page = Backbone.Relational.Model.extend(); 85 | 86 | ok( Backbone.Relational.store.getObjectByName( 'Page' ) === models.Page ); 87 | ok( Backbone.Relational.store.getObjectByName( 'Person' ) === window.Person ); 88 | 89 | Backbone.Relational.store.removeModelScope( models ); 90 | 91 | ok( !Backbone.Relational.store.getObjectByName( 'Page' ) ); 92 | ok( Backbone.Relational.store.getObjectByName( 'Person' ) === window.Person ); 93 | 94 | Backbone.Relational.store.removeModelScope( window ); 95 | 96 | ok( !Backbone.Relational.store.getObjectByName( 'Person' ) ); 97 | }); 98 | 99 | QUnit.test( "unregister", function() { 100 | var animalStoreColl = Backbone.Relational.store.getCollection( Animal ), 101 | animals = null, 102 | animal = null; 103 | 104 | // Single model 105 | animal = new Animal( { id: 'a1' } ); 106 | ok( Backbone.Relational.store.find( Animal, 'a1' ) === animal ); 107 | 108 | Backbone.Relational.store.unregister( animal ); 109 | ok( Backbone.Relational.store.find( Animal, 'a1' ) === null ); 110 | 111 | animal = new Animal( { id: 'a2' } ); 112 | ok( Backbone.Relational.store.find( Animal, 'a2' ) === animal ); 113 | 114 | animal.trigger( 'relational:unregister', animal ); 115 | ok( Backbone.Relational.store.find( Animal, 'a2' ) === null ); 116 | 117 | ok( animalStoreColl.size() === 0 ); 118 | 119 | // Collection 120 | animals = new AnimalCollection( [ { id: 'a3' }, { id: 'a4' } ] ); 121 | animal = animals.first(); 122 | 123 | ok( Backbone.Relational.store.find( Animal, 'a3' ) === animal ); 124 | ok( animalStoreColl.size() === 2 ); 125 | 126 | Backbone.Relational.store.unregister( animals ); 127 | ok( Backbone.Relational.store.find( Animal, 'a3' ) === null ); 128 | 129 | ok( animalStoreColl.size() === 0 ); 130 | 131 | // Store collection 132 | animals = new AnimalCollection( [ { id: 'a5' }, { id: 'a6' } ] ); 133 | ok( animalStoreColl.size() === 2 ); 134 | 135 | Backbone.Relational.store.unregister( animalStoreColl ); 136 | ok( animalStoreColl.size() === 0 ); 137 | 138 | // Model type 139 | animals = new AnimalCollection( [ { id: 'a7' }, { id: 'a8' } ] ); 140 | ok( animalStoreColl.size() === 2 ); 141 | 142 | Backbone.Relational.store.unregister( Animal ); 143 | ok( animalStoreColl.size() === 0 ); 144 | }); 145 | 146 | QUnit.test( "`eventQueue` is unblocked again after a duplicate id error", 3, function() { 147 | var node = new Node( { id: 1 } ); 148 | 149 | ok( Backbone.Relational.eventQueue.isBlocked() === false ); 150 | 151 | try { 152 | duplicateNode = new Node( { id: 1 } ); 153 | } 154 | catch( error ) { 155 | ok( true, "Duplicate id error thrown" ); 156 | } 157 | 158 | ok( Backbone.Relational.eventQueue.isBlocked() === false ); 159 | }); 160 | 161 | QUnit.test( "Don't allow setting a duplicate `id`", 4, function() { 162 | var a = new Zoo(); // This object starts with no id. 163 | var b = new Zoo( { 'id': 42 } ); // This object starts with an id of 42. 164 | 165 | equal( b.id, 42 ); 166 | 167 | try { 168 | a.set( 'id', 42 ); 169 | } 170 | catch( error ) { 171 | ok( true, "Duplicate id error thrown" ); 172 | } 173 | 174 | ok( !a.id, "a.id=" + a.id ); 175 | equal( b.id, 42 ); 176 | }); 177 | 178 | QUnit.test( "Models are created from objects, can then be found, destroyed, cannot be found anymore", function() { 179 | var houseId = 'house-10'; 180 | var personId = 'person-10'; 181 | 182 | var anotherHouse = new House({ 183 | id: houseId, 184 | location: 'no country for old men', 185 | resource_uri: houseId, 186 | occupants: [{ 187 | id: personId, 188 | name: 'Remi', 189 | resource_uri: personId 190 | }] 191 | }); 192 | 193 | ok( anotherHouse.get('occupants') instanceof Backbone.Relational.Collection, "Occupants is a Collection" ); 194 | ok( anotherHouse.get('occupants').get( personId ) instanceof Person, "Occupants contains the Person with id='" + personId + "'" ); 195 | 196 | var person = Backbone.Relational.store.find( Person, personId ); 197 | 198 | ok( person, "Person with id=" + personId + " is found in the store" ); 199 | 200 | var request = person.destroy(); 201 | // Trigger the 'success' callback to fire the 'destroy' event 202 | request.success(); 203 | 204 | person = Backbone.Relational.store.find( Person, personId ); 205 | 206 | ok( !person, personId + " is not found in the store anymore" ); 207 | ok( !anotherHouse.get('occupants').get( personId ), "Occupants no longer contains the Person with id='" + personId + "'" ); 208 | 209 | request = anotherHouse.destroy(); 210 | // Trigger the 'success' callback to fire the 'destroy' event 211 | request.success(); 212 | 213 | var house = Backbone.Relational.store.find( House, houseId ); 214 | 215 | ok( !house, houseId + " is not found in the store anymore" ); 216 | }); 217 | 218 | QUnit.test( "Model.collection is the first collection a Model is added to by an end-user (not its Backbone.Relational.Store collection!)", function() { 219 | var person = new Person( { id: 5, name: 'New guy' } ); 220 | var personColl = new PersonCollection(); 221 | personColl.add( person ); 222 | ok( person.collection === personColl ); 223 | }); 224 | 225 | QUnit.test( "Models don't get added to the store until the get an id", function() { 226 | var storeColl = Backbone.Relational.store.getCollection( Node ), 227 | node1 = new Node( { id: 1 } ), 228 | node2 = new Node(); 229 | 230 | ok( storeColl.contains( node1 ) ); 231 | ok( !storeColl.contains( node2 ) ); 232 | 233 | node2.set( { id: 2 } ); 234 | 235 | ok( storeColl.contains( node1 ) ); 236 | }); 237 | 238 | QUnit.test( "All models can be found after adding them to a Collection via 'Collection.reset'", function() { 239 | var nodes = [ 240 | { id: 1, parent: null }, 241 | { id: 2, parent: 1 }, 242 | { id: 3, parent: 4 }, 243 | { id: 4, parent: 1 } 244 | ]; 245 | 246 | var nodeList = new NodeList(); 247 | nodeList.reset( nodes ); 248 | 249 | var storeColl = Backbone.Relational.store.getCollection( Node ); 250 | equal( storeColl.length, 4, "Every Node is in Backbone.Relational.store" ); 251 | ok( Backbone.Relational.store.find( Node, 1 ) instanceof Node, "Node 1 can be found" ); 252 | ok( Backbone.Relational.store.find( Node, 2 ) instanceof Node, "Node 2 can be found" ); 253 | ok( Backbone.Relational.store.find( Node, 3 ) instanceof Node, "Node 3 can be found" ); 254 | ok( Backbone.Relational.store.find( Node, 4 ) instanceof Node, "Node 4 can be found" ); 255 | }); 256 | 257 | QUnit.test( "Inheritance creates and uses a separate collection", function() { 258 | var whale = new Animal( { id: 1, species: 'whale' } ); 259 | ok( Backbone.Relational.store.find( Animal, 1 ) === whale ); 260 | 261 | var numCollections = Backbone.Relational.store._collections.length; 262 | 263 | var Mammal = Animal.extend({ 264 | urlRoot: '/mammal/' 265 | }); 266 | 267 | var lion = new Mammal( { id: 1, species: 'lion' } ); 268 | var donkey = new Mammal( { id: 2, species: 'donkey' } ); 269 | 270 | equal( Backbone.Relational.store._collections.length, numCollections + 1 ); 271 | ok( Backbone.Relational.store.find( Animal, 1 ) === whale ); 272 | ok( Backbone.Relational.store.find( Mammal, 1 ) === lion ); 273 | ok( Backbone.Relational.store.find( Mammal, 2 ) === donkey ); 274 | 275 | var Primate = Mammal.extend({ 276 | urlRoot: '/primate/' 277 | }); 278 | 279 | var gorilla = new Primate( { id: 1, species: 'gorilla' } ); 280 | 281 | equal( Backbone.Relational.store._collections.length, numCollections + 2 ); 282 | ok( Backbone.Relational.store.find( Primate, 1 ) === gorilla ); 283 | }); 284 | 285 | QUnit.test( "Inheritance with `subModelTypes` uses the same collection as the model's super", function() { 286 | var Mammal = Animal.extend({ 287 | subModelTypes: { 288 | 'primate': 'Primate', 289 | 'carnivore': 'Carnivore' 290 | } 291 | }); 292 | 293 | window.Primate = Mammal.extend(); 294 | window.Carnivore = Mammal.extend(); 295 | 296 | var lion = new Carnivore( { id: 1, species: 'lion' } ); 297 | var wolf = new Carnivore( { id: 2, species: 'wolf' } ); 298 | 299 | var numCollections = Backbone.Relational.store._collections.length; 300 | 301 | var whale = new Mammal( { id: 3, species: 'whale' } ); 302 | 303 | equal( Backbone.Relational.store._collections.length, numCollections, "`_collections` should have remained the same" ); 304 | 305 | ok( Backbone.Relational.store.find( Mammal, 1 ) === lion ); 306 | ok( Backbone.Relational.store.find( Mammal, 2 ) === wolf ); 307 | ok( Backbone.Relational.store.find( Mammal, 3 ) === whale ); 308 | ok( Backbone.Relational.store.find( Carnivore, 1 ) === lion ); 309 | ok( Backbone.Relational.store.find( Carnivore, 2 ) === wolf ); 310 | ok( Backbone.Relational.store.find( Carnivore, 3 ) !== whale ); 311 | 312 | var gorilla = new Primate( { id: 4, species: 'gorilla' } ); 313 | 314 | equal( Backbone.Relational.store._collections.length, numCollections, "`_collections` should have remained the same" ); 315 | 316 | ok( Backbone.Relational.store.find( Animal, 4 ) !== gorilla ); 317 | ok( Backbone.Relational.store.find( Mammal, 4 ) === gorilla ); 318 | ok( Backbone.Relational.store.find( Primate, 4 ) === gorilla ); 319 | 320 | delete window.Primate; 321 | delete window.Carnivore; 322 | }); 323 | 324 | QUnit.test( "findOrCreate does not modify attributes hash if parse is used, prior to creating new model", function () { 325 | var model = Backbone.Relational.Model.extend({ 326 | parse: function( response ) { 327 | response.id = response.id + 'something'; 328 | return response; 329 | } 330 | }); 331 | var attributes = {id: 42, foo: "bar"}; 332 | var testAttributes = {id: 42, foo: "bar"}; 333 | 334 | model.findOrCreate( attributes, { parse: true, merge: false, create: false } ); 335 | 336 | ok( _.isEqual( attributes, testAttributes ), "attributes hash should not be modified" ); 337 | }); 338 | -------------------------------------------------------------------------------- /test/collection.js: -------------------------------------------------------------------------------- 1 | var semver = require('semver'); 2 | 3 | QUnit.module( "Backbone.Relational.Collection", { setup: require('./setup/setup').reset } ); 4 | 5 | QUnit.test( "Loading (fetching) multiple times updates the model, and relations's `keyContents`", function() { 6 | var collA = new Backbone.Relational.Collection(); 7 | collA.model = User; 8 | var collB = new Backbone.Relational.Collection(); 9 | collB.model = User; 10 | 11 | // Similar to what happens when calling 'fetch' on collA, updating it, calling 'fetch' on collB 12 | var name = 'User 1'; 13 | collA.add( { id: '/user/1/', name: name } ); 14 | var user = collA.at( 0 ); 15 | equal( user.get( 'name' ), name ); 16 | 17 | // The 'name' of 'user' is updated when adding a new hash to the collection 18 | name = 'New name'; 19 | collA.add( { id: '/user/1/', name: name }, { merge: true } ); 20 | var updatedUser = collA.at( 0 ); 21 | equal( user.get( 'name' ), name ); 22 | equal( updatedUser.get( 'name' ), name ); 23 | 24 | // The 'name' of 'user' is also updated when adding a new hash to another collection 25 | name = 'Another new name'; 26 | collB.add( { id: '/user/1/', name: name, title: 'Superuser' }, { merge: true } ); 27 | var updatedUser2 = collA.at( 0 ); 28 | equal( user.get( 'name' ), name ); 29 | equal( updatedUser2.get('name'), name ); 30 | 31 | //console.log( collA.models, collA.get( '/user/1/' ), user, updatedUser, updatedUser2 ); 32 | ok( collA.get( '/user/1/' ) === updatedUser ); 33 | ok( collA.get( '/user/1/' ) === updatedUser2 ); 34 | ok( collB.get( '/user/1/' ) === user ); 35 | }); 36 | 37 | QUnit.test( "Loading (fetching) a collection multiple times updates related models as well (HasOne)", function() { 38 | var coll = new PersonCollection(); 39 | coll.add( { id: 'person-10', name: 'Person', user: { id: 'user-10', login: 'User' } } ); 40 | 41 | var person = coll.at( 0 ); 42 | var user = person.get( 'user' ); 43 | 44 | equal( user.get( 'login' ), 'User' ); 45 | 46 | coll.add( { id: 'person-10', name: 'New person', user: { id: 'user-10', login: 'New user' } }, { merge: true } ); 47 | 48 | equal( person.get( 'name' ), 'New person' ); 49 | equal( user.get( 'login' ), 'New user' ); 50 | }); 51 | 52 | QUnit.test( "Loading (fetching) a collection multiple times updates related models as well (HasMany)", function() { 53 | var coll = new Backbone.Relational.Collection(); 54 | coll.model = Zoo; 55 | 56 | // Create a 'zoo' with 1 animal in it 57 | coll.add( { id: 'zoo-1', name: 'Zoo', animals: [ { id: 'lion-1', name: 'Mufasa' } ] } ); 58 | var zoo = coll.at( 0 ); 59 | var lion = zoo.get( 'animals' ) .at( 0 ); 60 | 61 | equal( lion.get( 'name' ), 'Mufasa' ); 62 | 63 | // Update the name of 'zoo' and 'lion' 64 | coll.add( { id: 'zoo-1', name: 'Zoo Station', animals: [ { id: 'lion-1', name: 'Simba' } ] }, { merge: true } ); 65 | 66 | equal( zoo.get( 'name' ), 'Zoo Station' ); 67 | equal( lion.get( 'name' ), 'Simba' ); 68 | }); 69 | 70 | QUnit.test( "reset should use `merge: true` by default", function() { 71 | var nodeList = new NodeList(); 72 | 73 | nodeList.add( [ { id: 1 }, { id: 2, parent: 1 } ] ); 74 | 75 | var node1 = nodeList.get( 1 ), 76 | node2 = nodeList.get( 2 ); 77 | 78 | ok( node2.get( 'parent' ) === node1 ); 79 | ok( !node1.get( 'parent' ) ); 80 | 81 | nodeList.reset( [ { id: 1, parent: 2 } ] ); 82 | 83 | ok( node1.get( 'parent' ) === node2 ); 84 | }); 85 | 86 | QUnit.test( "Return values for add/remove/reset/set match plain Backbone's", function() { 87 | var Car = Backbone.Relational.Model.extend(), 88 | Cars = Backbone.Relational.Collection.extend( { model: Car } ), 89 | cars = new Cars(); 90 | 91 | ok( cars.add( { name: 'A' } ) instanceof Car, "Add one model" ); 92 | 93 | var added = cars.add( [ { name: 'B' }, { name: 'C' } ] ); 94 | ok( _.isArray( added ), "Added (an array of) two models" ); 95 | ok( added.length === 2 ); 96 | 97 | ok( cars.remove( cars.at( 0 ) ) instanceof Car, "Remove one model" ); 98 | var removed = cars.remove( [ cars.at( 0 ), cars.at( 1 ) ] ); 99 | ok( _.isArray( removed ), "Remove (an array of) two models" ); 100 | ok( removed.length === 2 ); 101 | 102 | ok( cars.reset( { name: 'D' } ) instanceof Car, "Reset with one model" ); 103 | var reset = cars.reset( [ { name: 'E' }, { name: 'F' } ] ); 104 | ok( _.isArray( reset ), "Reset (an array of) two models" ); 105 | ok( reset.length === 2 ); 106 | ok( cars.length === 2 ); 107 | 108 | var e = cars.at(0), 109 | f = cars.at(1); 110 | 111 | ok( cars.set( e ) instanceof Car, "Set one model" ); 112 | ok( _.isArray( cars.set( [ e, f ] ) ), "Set (an array of) two models" ); 113 | // Check removing `[]` 114 | var result = cars.remove( [] ); 115 | 116 | //have to also check if the result is an array since in backbone 1.3.1 Backbone.VERSION is incorrectly set to 1.2.3 117 | if (semver.satisfies(Backbone.VERSION, '^1.3.1') || Array.isArray(result)){ 118 | ok( result.length === 0, "Removing `[]` is a noop (results in an empty array, no models removed)" ); 119 | } else { 120 | ok( result === false, "Removing `[]` is a noop (results in 'false', no models removed)" ); 121 | } 122 | ok( cars.length === 2, "Still 2 cars" ); 123 | 124 | // Check removing `null` 125 | result = cars.remove( null ); 126 | ok( _.isUndefined( result ), "Removing `null` is a noop" ); 127 | ok( cars.length === 2, "Still 2 cars" ); 128 | 129 | // Check setting to `[]` 130 | result = cars.set( [] ); 131 | ok( _.isArray( result ) && !result.length, "Set `[]` empties collection" ); 132 | ok( cars.length === 0, "All cars gone" ); 133 | 134 | cars.set( [ e, f ] ); 135 | ok( cars.length === 2, "2 cars again" ); 136 | 137 | // Check setting `null` 138 | // ok( _.isUndefined( cars.set( null ) ), "Set `null` empties collection" ); 139 | ok( _.isUndefined( cars.set( null ) ), "Set `null` causes noop on collection" ); 140 | console.log( cars, cars.length ); 141 | // ok( cars.length === 0, "All cars gone" ); 142 | ok( cars.length === 2, "All cars still exist" ); 143 | }); 144 | 145 | QUnit.test( "add/remove/set (with `add`, `remove` and `merge` options)", function() { 146 | var coll = new AnimalCollection(); 147 | 148 | /** 149 | * Add 150 | */ 151 | coll.add( { id: '1', species: 'giraffe' } ); 152 | 153 | ok( coll.length === 1 ); 154 | 155 | coll.add( { id: 1, species: 'giraffe' } ); 156 | 157 | ok( coll.length === 1 ); 158 | 159 | coll.add([ 160 | { 161 | id: 1, species: 'giraffe' 162 | }, 163 | { 164 | id: 2, species: 'gorilla' 165 | } 166 | ]); 167 | 168 | var giraffe = coll.get( 1 ), 169 | gorilla = coll.get( 2 ), 170 | dolphin = new Animal( { species: 'dolphin' } ), 171 | hippo = new Animal( { id: 4, species: 'hippo' } ); 172 | 173 | ok( coll.length === 2 ); 174 | 175 | coll.add( dolphin ); 176 | 177 | ok( coll.length === 3 ); 178 | 179 | // Update won't do anything 180 | coll.add( { id: 1, species: 'giraffe', name: 'Long John' } ); 181 | 182 | ok( !coll.get( 1 ).get( 'name' ), 'name=' + coll.get( 1 ).get( 'name' ) ); 183 | 184 | // Update with `merge: true` will update the animal 185 | coll.add( { id: 1, species: 'giraffe', name: 'Long John' }, { merge: true } ); 186 | 187 | ok( coll.get( 1 ).get( 'name' ) === 'Long John' ); 188 | 189 | /** 190 | * Remove 191 | */ 192 | coll.remove( 1 ); 193 | 194 | ok( coll.length === 2 ); 195 | ok( !coll.get( 1 ), "`giraffe` removed from coll" ); 196 | 197 | coll.remove( dolphin ); 198 | 199 | ok( coll.length === 1 ); 200 | ok( coll.get( 2 ) === gorilla, "Only `gorilla` is left in coll" ); 201 | 202 | /** 203 | * Update 204 | */ 205 | coll.add( giraffe ); 206 | 207 | // This shouldn't do much at all 208 | var options = { add: false, merge: false, remove: false }; 209 | coll.set( [ dolphin, { id: 2, name: 'Silverback' } ], options ); 210 | 211 | ok( coll.length === 2 ); 212 | ok( coll.get( 2 ) === gorilla, "`gorilla` is left in coll" ); 213 | ok( !coll.get( 2 ).get( 'name' ), "`gorilla` name not updated" ); 214 | 215 | // This should remove `giraffe`, add `hippo`, leave `dolphin`, and update `gorilla`. 216 | options = { add: true, merge: true, remove: true }; 217 | coll.set( [ 4, dolphin, { id: 2, name: 'Silverback' } ], options ); 218 | 219 | ok( coll.length === 3 ); 220 | ok( !coll.get( 1 ), "`giraffe` removed from coll" ); 221 | equal( coll.get( 2 ), gorilla ); 222 | ok( !coll.get( 3 ) ); 223 | equal( coll.get( 4 ), hippo ); 224 | equal( coll.get( dolphin ), dolphin ); 225 | equal( gorilla.get( 'name' ), 'Silverback' ); 226 | }); 227 | 228 | QUnit.test( "add/remove/set on a relation (with `add`, `remove` and `merge` options)", function() { 229 | var zoo = new Zoo(), 230 | animals = zoo.get( 'animals' ), 231 | a = new Animal( { id: 'a' } ), 232 | b = new Animal( { id: 'b' } ), 233 | c = new Animal( { id: 'c' } ); 234 | 235 | // The default is to call `Collection.update` without specifying options explicitly; 236 | // the defaults are { add: true, merge: true, remove: true }. 237 | zoo.set( 'animals', [ a ] ); 238 | ok( animals.length === 1, 'animals.length=' + animals.length + ' == 1?' ); 239 | 240 | zoo.set( 'animals', [ a, b ], { add: false, merge: true, remove: true } ); 241 | ok( animals.length === 1, 'animals.length=' + animals.length + ' == 1?' ); 242 | 243 | zoo.set( 'animals', [ b ], { add: false, merge: false, remove: true } ); 244 | ok( animals.length === 0, 'animals.length=' + animals.length + ' == 0?' ); 245 | 246 | zoo.set( 'animals', [ { id: 'a', species: 'a' } ], { add: false, merge: true, remove: false } ); 247 | ok( animals.length === 0, 'animals.length=' + animals.length + ' == 0?' ); 248 | ok( a.get( 'species' ) === 'a', "`a` not added, but attributes did get merged" ); 249 | 250 | zoo.set( 'animals', [ { id: 'b', species: 'b' } ], { add: true, merge: false, remove: false } ); 251 | ok( animals.length === 1, 'animals.length=' + animals.length + ' == 1?' ); 252 | ok( !b.get( 'species' ), "`b` added, but attributes did not get merged" ); 253 | 254 | zoo.set( 'animals', [ { id: 'c', species: 'c' } ], { add: true, merge: false, remove: true } ); 255 | ok( animals.length === 1, 'animals.length=' + animals.length + ' == 1?' ); 256 | ok( !animals.get( 'b' ), "b removed from animals" ); 257 | ok( animals.get( 'c' ) === c, "c added to animals" ); 258 | ok( !c.get( 'species' ), "`c` added, but attributes did not get merged" ); 259 | 260 | zoo.set( 'animals', [ a, { id: 'b', species: 'b' } ] ); 261 | ok( animals.length === 2, 'animals.length=' + animals.length + ' == 2?' ); 262 | ok( b.get( 'species' ) === 'b', "`b` added, attributes got merged" ); 263 | ok( !animals.get( 'c' ), "c removed from animals" ); 264 | 265 | zoo.set( 'animals', [ { id: 'c', species: 'c' } ], { add: true, merge: true, remove: false } ); 266 | ok( animals.length === 3, 'animals.length=' + animals.length + ' == 3?' ); 267 | ok( c.get( 'species' ) === 'c', "`c` added, attributes got merged" ); 268 | }); 269 | 270 | QUnit.test( "`merge` on a nested relation", function() { 271 | var zoo = new Zoo( { id: 1, animals: [ { id: 'a' } ] } ), 272 | animals = zoo.get( 'animals' ), 273 | a = animals.get( 'a' ); 274 | 275 | ok( a.get( 'livesIn' ) === zoo, "`a` is in `zoo`" ); 276 | 277 | // Pass a non-default option to a new model, with an existing nested model 278 | var zoo2 = new Zoo( { id: 2, animals: [ { id: 'a', species: 'a' } ] }, { merge: false } ); 279 | 280 | ok( a.get( 'livesIn' ) === zoo2, "`a` is in `zoo2`" ); 281 | ok( !a.get( 'species' ), "`a` hasn't gotten merged" ); 282 | }); 283 | 284 | QUnit.test( "pop", function() { 285 | var zoo = new Zoo({ 286 | animals: [ { name: 'a' } ] 287 | }), 288 | animals = zoo.get( 'animals' ); 289 | 290 | var a = animals.pop(), 291 | b = animals.pop(); 292 | 293 | ok( a && a.get( 'name' ) === 'a' ); 294 | ok( typeof b === 'undefined' ); 295 | }); 296 | 297 | QUnit.test( "Adding a new model doesn't `merge` it onto itself", function() { 298 | var TreeModel = Backbone.Relational.Model.extend({ 299 | relations: [ 300 | { 301 | key: 'parent', 302 | type: Backbone.Relational.HasOne 303 | } 304 | ], 305 | 306 | initialize: function( options ) { 307 | if ( coll ) { 308 | coll.add( this ); 309 | } 310 | } 311 | }); 312 | 313 | var TreeCollection = Backbone.Relational.Collection.extend({ 314 | model: TreeModel 315 | }); 316 | 317 | // Using `set` to add a new model, since this is what would be called when `fetch`ing model(s) 318 | var coll = new TreeCollection(), 319 | model = coll.set( { id: 'm2', name: 'new model', parent: 'm1' } ); 320 | 321 | ok( model instanceof TreeModel ); 322 | ok( coll.size() === 1, "One model in coll" ); 323 | 324 | equal( model.get( 'parent' ), null ); 325 | equal( model.get( 'name' ), 'new model' ); 326 | deepEqual( model.getIdsToFetch( 'parent' ), [ 'm1' ] ); 327 | 328 | model = coll.set( { id: 'm2', name: 'updated model', parent: 'm1' } ); 329 | equal( model.get( 'name' ), 'updated model' ); 330 | deepEqual( model.getIdsToFetch( 'parent' ), [ 'm1' ] ); 331 | }); 332 | -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | header, nav, section, article, aside, footer, hgroup { 2 | display: block; 3 | } 4 | 5 | @font-face { 6 | font-family: 'MuseoSlab500'; 7 | font-weight: normal; 8 | font-style: normal; 9 | src: url('../fonts/Museo_Slab_500.eot'); 10 | src: local('Museo Slab 500'), local('MuseoSlab-500'), url('../fonts/Museo_Slab_500.woff') format('woff'), url('../fonts/Museo_Slab_500.otf') format('opentype'); 11 | } 12 | 13 | @font-face { 14 | font-family: 'PT Sans'; 15 | font-style: normal; 16 | font-weight: 400; 17 | src: local('PT Sans'), local('PTSans-Regular'), url('../fonts/PT_Sans.woff') format('woff'); 18 | } 19 | 20 | body { 21 | font-size: 16px; 22 | line-height: 1.5em; 23 | font-family: "PT Sans", "Helvetica Neue", "Lucida Grande", "Lucida Sans Unicode", Helvetica, Arial, sans-serif; 24 | background: #f4f4f4; 25 | } 26 | 27 | a, a:visited { 28 | color: #444; 29 | text-decoration: underline; 30 | } 31 | a:active, a:hover { 32 | color: #000; 33 | } 34 | 35 | h1, h2, h3, h4, h5, h6, .toc_title { 36 | font-family: MuseoSlab500, "Lucida Grande", "Lucida Sans Unicode", "Helvetica Neue", "Bitstream Vera Sans", Helvetica, Arial, sans-serif; 37 | } 38 | h1, h2 { 39 | text-shadow: 0 1px 0 #FFFFFF; 40 | } 41 | h1 { 42 | font-size: 2em; 43 | line-height: 1.5; 44 | margin: 0.75em 0 0.75em 0; 45 | } 46 | h2 { 47 | font-size: 1.5em; 48 | line-height: 1.25; 49 | margin: 1.5833em 0 0.6667em; 50 | } 51 | h3 { 52 | font-size: 1.25em; 53 | line-height: 1.2; 54 | margin: 1.8em 0 0.6em; 55 | } 56 | h4 { 57 | font-size: 1.125em; 58 | line-height: 1.3333; 59 | margin: 2em 0 0.66667em; 60 | } 61 | h5, h6 { 62 | font-size: 1em; 63 | line-height: 1.5; 64 | margin: 1.5em 0 0; 65 | } 66 | h1 small, h2 small, h3 small, h4 small, h5 small, h6 small { 67 | font-size: 12px; 68 | font-family: "Helvetica Neue", "Lucida Grande", "Lucida Sans Unicode", Helvetica, Arial, sans-serif; 69 | font-weight: normal; 70 | } 71 | 72 | p, ol, ul, dl, pre, figure, footer { 73 | margin: 0 0 1.5em 0; 74 | } 75 | 76 | ul { 77 | padding-left: 0; 78 | list-style-type: none; 79 | } 80 | ul li { 81 | margin: 0; 82 | } 83 | ul li:before { 84 | content: "\2013"; 85 | position: relative; 86 | left: -1ex; 87 | } 88 | 89 | dl { 90 | clear: both; 91 | } 92 | dl dt { 93 | clear: left; 94 | float: left; 95 | margin-bottom: .5em; 96 | width: 5em; 97 | } 98 | dl dd { 99 | margin-left: 5em; 100 | margin-bottom: .5em; 101 | } 102 | 103 | .button { 104 | display: inline-block; 105 | outline: none; 106 | cursor: pointer; 107 | text-align: center; 108 | text-decoration: none; 109 | font: 14px/100% Arial, Helvetica, sans-serif; 110 | padding: .5em 2em .55em; 111 | text-shadow: 0 1px 1px rgba(0,0,0,.3); 112 | -webkit-border-radius: .4em; 113 | -moz-border-radius: .4em; 114 | border-radius: .4em; 115 | -webkit-box-shadow: 0 1px 2px rgba(0,0,0,.2); 116 | -moz-box-shadow: 0 1px 2px rgba(0,0,0,.2); 117 | box-shadow: 0 1px 2px rgba(0,0,0,.2); 118 | } 119 | .button:hover { 120 | text-decoration: none; 121 | } 122 | .button:active { 123 | position: relative; 124 | top: 1px; 125 | } 126 | 127 | /* white */ 128 | .white { 129 | color: #606060; 130 | border: solid 1px #b7b7b7; 131 | background: #fff; 132 | background: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#ededed)); 133 | background: -moz-linear-gradient(top, #fff, #ededed); 134 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#ededed'); 135 | } 136 | .white:hover { 137 | background: #ededed; 138 | background: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#dcdcdc)); 139 | background: -moz-linear-gradient(top, #fff, #dcdcdc); 140 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#dcdcdc'); 141 | } 142 | .white:active { 143 | color: #999; 144 | background: -webkit-gradient(linear, left top, left bottom, from(#ededed), to(#fff)); 145 | background: -moz-linear-gradient(top, #ededed, #fff); 146 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ededed', endColorstr='#ffffff'); 147 | } 148 | 149 | /* orange */ 150 | .orange { 151 | color: #fef4e9; 152 | border: solid 1px #da7c0c; 153 | background: #f78d1d; 154 | background: -webkit-gradient(linear, left top, left bottom, from(#faa51a), to(#f47a20)); 155 | background: -moz-linear-gradient(top, #faa51a, #f47a20); 156 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#faa51a', endColorstr='#f47a20'); 157 | } 158 | .orange:hover { 159 | color: #fef4e9; 160 | background: #f47c20; 161 | background: -webkit-gradient(linear, left top, left bottom, from(#f88e11), to(#f06015)); 162 | background: -moz-linear-gradient(top, #f88e11, #f06015); 163 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#f88e11', endColorstr='#f06015'); 164 | } 165 | .orange:active { 166 | color: #fcd3a5; 167 | background: -webkit-gradient(linear, left top, left bottom, from(#f47a20), to(#faa51a)); 168 | background: -moz-linear-gradient(top, #f47a20, #faa51a); 169 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#f47a20', endColorstr='#faa51a'); 170 | } 171 | 172 | div#sidebar { 173 | background: #fff; 174 | position: fixed; 175 | z-index: 10; 176 | top: 0; 177 | left: 0; 178 | bottom: 0; 179 | width: 240px; 180 | overflow-y: auto; 181 | overflow-x: hidden; 182 | -webkit-overflow-scrolling: touch; 183 | padding: 15px 15px 30px 30px; 184 | border-right: 1px solid #bbb; 185 | -webkit-box-shadow: 0 0 20px #ccc; 186 | -moz-box-shadow: 0 0 20px #ccc; 187 | box-shadow: 0 0 20px #ccc; 188 | } 189 | div#sidebar a, a:visited { 190 | color: black; 191 | text-decoration: none; 192 | } 193 | div#sidebar a:hover { 194 | text-decoration: underline; 195 | } 196 | a.toc_title, a.toc_title:visited { 197 | display: block; 198 | font-weight: bold; 199 | margin: 1em 0 .2em; 200 | } 201 | div#sidebar .version { 202 | font-size: 10px; 203 | font-weight: normal; 204 | } 205 | div#sidebar ul { 206 | font-size: 12px; 207 | line-height: 20px; 208 | margin-bottom: .5em; 209 | } 210 | div#sidebar ul ul { 211 | margin: 0 0 0 1em; 212 | } 213 | div#sidebar li.link_out:before { 214 | content: "\00BB"; 215 | } 216 | 217 | div.container { 218 | position: relative; 219 | width: 45em; 220 | margin: 40px 0 50px 320px; 221 | } 222 | div.container ul li { 223 | margin-bottom: .5em; 224 | text-indent: -1.5ex; 225 | } 226 | div.container ul.small { 227 | font-size: 14px; 228 | } 229 | 230 | div.run { 231 | cursor: pointer; 232 | font-family: Arial, Courier, Times New Roman, serif; 233 | font-size: 16px; 234 | position: absolute; 235 | bottom: 15px; 236 | right: 15px; 237 | } 238 | div.run a { 239 | border-radius: .2em; 240 | padding: .2em .5em .2em .6em; 241 | } 242 | 243 | p.warning { 244 | font-size: 13px; 245 | font-style: italic; 246 | } 247 | 248 | p.notice { 249 | background-color: #ffeeaa; 250 | border: 0 solid #eedd88; 251 | border-left-width: 5px; 252 | box-shadow: 0 0 6px rgba(0, 0, 0, 0.5); 253 | color: #443; 254 | font-size: 12px; 255 | padding: 2px 6px 2px 15px; 256 | margin: 1.5em 0 1.5em -1em; 257 | } 258 | 259 | a.punch { 260 | display: inline-block; 261 | background: #4162a8; 262 | border-top: 1px solid #38538c; 263 | border-right: 1px solid #1f2d4d; 264 | border-bottom: 1px solid #151e33; 265 | border-left: 1px solid #1f2d4d; 266 | border-radius: .4em; 267 | -webkit-box-shadow: inset 0 1px 10px 1px #5c8bee, 0px 1px 0 #1d2c4d, 0 6px 0px #1f3053, 0 8px 4px 1px #333; 268 | -moz-box-shadow: inset 0 1px 10px 1px #5c8bee, 0px 1px 0 #1d2c4d, 0 6px 0px #1f3053, 0 8px 4px 1px #333; 269 | box-shadow: inset 0 1px 10px 1px #5c8bee, 0px 1px 0 #1d2c4d, 0 6px 0px #1f3053, 0 8px 4px 1px #333; 270 | color: #fff; 271 | font-weight: bold; 272 | line-height: 1; 273 | margin-bottom: 15px; 274 | padding: 10px 0; 275 | text-align: center; 276 | text-shadow: 0px -1px 1px #1e2d4d; 277 | text-decoration: none; 278 | width: 225px; 279 | -webkit-background-clip: padding-box; 280 | } 281 | a.punch:hover { 282 | -webkit-box-shadow: inset 0 0px 20px 1px #87adff, 0px 1px 0 #1d2c4d, 0 6px 0px #1f3053, 0 8px 4px 1px #333; 283 | -moz-box-shadow: inset 0 0px 20px 1px #87adff, 0px 1px 0 #1d2c4d, 0 6px 0px #1f3053, 0 8px 4px 1px #333; 284 | box-shadow: inset 0 0px 20px 1px #87adff, 0px 1px 0 #1d2c4d, 0 6px 0px #1f3053, 0 8px 4px 1px #333; 285 | cursor: pointer; 286 | } 287 | a.punch:active { 288 | -webkit-box-shadow: inset 0 1px 10px 1px #5c8bee, 0 1px 0 #1d2c4d, 0 2px 0 #1f3053, 0 4px 3px 0 #333; 289 | -moz-box-shadow: inset 0 1px 10px 1px #5c8bee, 0 1px 0 #1d2c4d, 0 2px 0 #1f3053, 0 4px 3px 0 #333; 290 | box-shadow: inset 0 1px 10px 1px #5c8bee, 0 1px 0 #1d2c4d, 0 2px 0 #1f3053, 0 4px 3px 0 #333; 291 | margin-top: 5px; margin-bottom: 10px; 292 | } 293 | 294 | a img { 295 | border: 0; 296 | } 297 | 298 | img.example_image { 299 | margin: 0 auto; 300 | } 301 | img.example_retina { 302 | margin: 20px; 303 | box-shadow: 0 8px 15px rgba(0,0,0,0.4); 304 | } 305 | 306 | span.alias { 307 | font-size: 14px; 308 | font-style: italic; 309 | margin-left: 20px; 310 | } 311 | 312 | table { 313 | margin: 15px 0 0; padding: 0; 314 | } 315 | table .rule { 316 | height: 1px; 317 | background: #ccc; 318 | margin: 5px 0; 319 | } 320 | tr, td { 321 | margin: 0; padding: 0; 322 | } 323 | td { 324 | padding: 0px 15px 5px 0; 325 | } 326 | 327 | h3.code, h4.code, h5.code, code, pre, q { 328 | font-family: Consolas, Monaco, 'Andale Mono', 'Liberation Mono', 'Lucida Console', monospace; 329 | font-style: normal; 330 | } 331 | code, pre, q { 332 | font-size: 13px; 333 | font-weight: normal; 334 | line-height: 18px; 335 | 336 | direction: ltr; 337 | text-align: left; 338 | white-space: pre; 339 | word-spacing: normal; 340 | 341 | -moz-tab-size: 4; 342 | -o-tab-size: 4; 343 | tab-size: 4; 344 | 345 | -webkit-hyphens: none; 346 | -moz-hyphens: none; 347 | -ms-hyphens: none; 348 | hyphens: none; 349 | } 350 | q:before, q:after { 351 | content: ''; 352 | } 353 | q { 354 | border-radius: .2em; 355 | display: inline-block; /* Prevents the syndrome where the left border and part of the padding are rendered on the previous line */ 356 | padding: 0 3px; 357 | margin: 0; 358 | background: #fff; 359 | border: 1px solid #ddd; 360 | white-space: nowrap; 361 | } 362 | li q { 363 | display: inline; /* No idea why, but having them `inline-block` in a list gets the sizing wrong */ 364 | } 365 | a q { 366 | border-bottom: none; 367 | color: #111; 368 | text-decoration: underline; 369 | } 370 | a:hover q { 371 | color: #000; 372 | } 373 | code { 374 | margin-left: 20px; 375 | } 376 | h4 code { 377 | white-space: pre-wrap; 378 | } 379 | pre { 380 | box-shadow: 0 0 6px rgba(0, 0, 0, 0.5); 381 | font-size: 12px; 382 | padding: 2px 6px 2px 15px; 383 | position: relative; 384 | border: 0 solid #aaa; 385 | border-left-width: 5px; 386 | margin: 1.5em 0 1.5em -1em; 387 | } 388 | pre code { 389 | margin: 0; 390 | } 391 | pre.nomargin { 392 | border-top: 1px solid #444; 393 | margin-top: -1.5em; 394 | } 395 | 396 | #change-log small { 397 | float: right; 398 | } 399 | #change-log small .date { 400 | color: #999; 401 | } 402 | 403 | 404 | @media only screen and (-webkit-max-device-pixel-ratio: 1) and (max-width: 600px), 405 | only screen and (max--moz-device-pixel-ratio: 1) and (max-width: 600px) { 406 | div#sidebar { 407 | display: none; 408 | } 409 | img#logo { 410 | max-width: 450px; 411 | width: 100%; 412 | height: auto; 413 | } 414 | div.container { 415 | width: auto; 416 | margin-left: 15px; 417 | margin-right: 15px; 418 | } 419 | p, div.container ul { 420 | width: auto; 421 | } 422 | } 423 | 424 | 425 | @media only screen and (-webkit-min-device-pixel-ratio: 1.5) and (max-width: 640px), 426 | only screen and (-o-min-device-pixel-ratio: 3/2) and (max-width: 640px), 427 | only screen and (min-device-pixel-ratio: 1.5) and (max-width: 640px) { 428 | img { 429 | max-width: 100%; 430 | height: auto; 431 | } 432 | div#sidebar { 433 | -webkit-overflow-scrolling: initial; 434 | position: relative; 435 | width: 90%; 436 | height: 120px; 437 | left: 0; 438 | top: -7px; 439 | padding: 10px 0 10px 30px; 440 | border: 0; 441 | } 442 | img#logo { 443 | width: auto; 444 | height: auto; 445 | } 446 | div.container { 447 | margin: 0; 448 | width: 100%; 449 | } 450 | p, div.container ul { 451 | max-width: 98%; 452 | overflow-x: scroll; 453 | } 454 | table { 455 | position: relative; 456 | } 457 | tr:first-child td { 458 | padding-bottom: 25px; 459 | } 460 | td.text { 461 | padding: 0; 462 | position: absolute; 463 | left: 0; 464 | top: 48px; 465 | } 466 | tr:last-child td.text { 467 | top: 122px; 468 | } 469 | pre { 470 | overflow: scroll; 471 | } 472 | } 473 | 474 | /** 475 | * Prism 476 | */ 477 | 478 | code[class*="language-"], 479 | pre[class*="language-"] { 480 | color: #FFF; 481 | } 482 | 483 | /* Code blocks */ 484 | pre[class*="language-"] { 485 | padding: 1em; 486 | overflow: auto; 487 | } 488 | 489 | :not(pre) > code[class*="language-"], 490 | pre[class*="language-"] { 491 | background: #0C131C; 492 | } 493 | 494 | /* Inline code */ 495 | :not(pre) > code[class*="language-"] { 496 | padding: .1em; 497 | border-radius: .3em; 498 | } 499 | 500 | .token.comment, 501 | .token.prolog, 502 | .token.doctype, 503 | .token.cdata { 504 | color: #3FBF3F; 505 | } 506 | 507 | .token.punctuation { 508 | color: #FFF; 509 | } 510 | 511 | .namespace { 512 | opacity: .7; 513 | } 514 | 515 | .token.property, 516 | .token.boolean, 517 | .token.number { 518 | color: #FF8080; 519 | } 520 | 521 | .token.selector, 522 | .token.attr-value, 523 | .token.string { 524 | color: #A0FFA0; 525 | } 526 | 527 | .token.attr-name, 528 | .token.operator, 529 | .token.entity, 530 | .token.url, 531 | .language-css .token.string, 532 | .style .token.string { 533 | color: #FFF; 534 | } 535 | 536 | .token.tag, 537 | .token.atrule, 538 | .token.keyword { 539 | color: #0099FF; 540 | font-weight: bold; 541 | } 542 | .token.atrule, 543 | .token.keyword { 544 | font-style: italic; 545 | } 546 | 547 | .token.regex, 548 | .token.important { 549 | color: #e90; 550 | } 551 | 552 | .token.important { 553 | font-weight: bold; 554 | } 555 | 556 | .token.entity { 557 | cursor: help; 558 | } 559 | -------------------------------------------------------------------------------- /test/has-many.js: -------------------------------------------------------------------------------- 1 | QUnit.module( "Backbone.Relational.HasMany", { setup: require('./setup/data') } ); 2 | 3 | QUnit.test( "Listeners on 'add'/'remove'", 7, function() { 4 | ourHouse 5 | .on( 'add:occupants', function( model, coll ) { 6 | ok( model === person1, "model === person1" ); 7 | }) 8 | .on( 'remove:occupants', function( model, coll ) { 9 | ok( model === person1, "model === person1" ); 10 | }); 11 | 12 | theirHouse 13 | .on( 'add:occupants', function( model, coll ) { 14 | ok( model === person1, "model === person1" ); 15 | }) 16 | .on( 'remove:occupants', function( model, coll ) { 17 | ok( model === person1, "model === person1" ); 18 | }); 19 | 20 | var count = 0; 21 | person1.on( 'change:livesIn', function( model, attr ) { 22 | if ( count === 0 ) { 23 | ok( attr === ourHouse, "model === ourHouse" ); 24 | } 25 | else if ( count === 1 ) { 26 | ok( attr === theirHouse, "model === theirHouse" ); 27 | } 28 | else if ( count === 2 ) { 29 | ok( attr === null, "model === null" ); 30 | } 31 | 32 | count++; 33 | }); 34 | 35 | ourHouse.get( 'occupants' ).add( person1 ); 36 | person1.set( { 'livesIn': theirHouse } ); 37 | theirHouse.get( 'occupants' ).remove( person1 ); 38 | }); 39 | 40 | QUnit.test( "Listeners for 'add'/'remove', on a HasMany relation, for a Model with multiple relations", function() { 41 | var job1 = { company: oldCompany }; 42 | var job2 = { company: oldCompany, person: person1 }; 43 | var job3 = { person: person1 }; 44 | var newJob = null; 45 | 46 | newCompany.on( 'add:employees', function( model, coll ) { 47 | ok( false, "person1 should only be added to 'oldCompany'." ); 48 | }); 49 | 50 | // Assert that all relations on a Model are set up, before notifying related models. 51 | oldCompany.on( 'add:employees', function( model, coll ) { 52 | newJob = model; 53 | 54 | ok( model instanceof Job ); 55 | ok( model.get('company') instanceof Company && model.get('person') instanceof Person, 56 | "Both Person and Company are set on the Job instance" ); 57 | }); 58 | 59 | person1.on( 'add:jobs', function( model, coll ) { 60 | ok( model.get( 'company' ) === oldCompany && model.get( 'person' ) === person1, 61 | "Both Person and Company are set on the Job instance" ); 62 | }); 63 | 64 | // Add job1 and job2 to the 'Person' side of the relation 65 | var jobs = person1.get( 'jobs' ); 66 | 67 | jobs.add( job1 ); 68 | ok( jobs.length === 1, "jobs.length is 1" ); 69 | 70 | newJob.destroy(); 71 | ok( jobs.length === 0, "jobs.length is 0" ); 72 | 73 | jobs.add( job2 ); 74 | ok( jobs.length === 1, "jobs.length is 1" ); 75 | 76 | newJob.destroy(); 77 | ok( jobs.length === 0, "jobs.length is 0" ); 78 | 79 | // Add job1 and job2 to the 'Company' side of the relation 80 | var employees = oldCompany.get('employees'); 81 | 82 | employees.add( job3 ); 83 | ok( employees.length === 2, "employees.length is 2" ); 84 | 85 | newJob.destroy(); 86 | ok( employees.length === 1, "employees.length is 1" ); 87 | 88 | employees.add( job2 ); 89 | ok( employees.length === 2, "employees.length is 2" ); 90 | 91 | newJob.destroy(); 92 | ok( employees.length === 1, "employees.length is 1" ); 93 | 94 | // Create a stand-alone Job ;) 95 | new Job({ 96 | person: person1, 97 | company: oldCompany 98 | }); 99 | 100 | ok( jobs.length === 1 && employees.length === 2, "jobs.length is 1 and employees.length is 2" ); 101 | }); 102 | 103 | QUnit.test( "The Collections used for HasMany relations are re-used if possible", function() { 104 | var collId = ourHouse.get( 'occupants' ).id = 1; 105 | 106 | ourHouse.get( 'occupants' ).add( person1 ); 107 | ok( ourHouse.get( 'occupants' ).id === collId ); 108 | 109 | // Set a value on 'occupants' that would cause the relation to be reset. 110 | // The collection itself should be kept (along with it's properties) 111 | ourHouse.set( { 'occupants': [ 'person-1' ] } ); 112 | ok( ourHouse.get( 'occupants' ).id === collId ); 113 | ok( ourHouse.get( 'occupants' ).length === 1 ); 114 | 115 | // Setting a new collection loses the original collection 116 | ourHouse.set( { 'occupants': new Backbone.Relational.Collection() } ); 117 | ok( ourHouse.get( 'occupants' ).id === undefined ); 118 | }); 119 | 120 | 121 | QUnit.test( "On `set`, or creation, accept a collection or an array of ids/objects/models", function() { 122 | // Handle an array of ids 123 | var visitor1 = new Visitor( { id: 'visitor-1', name: 'Mr. Pink' } ), 124 | visitor2 = new Visitor( { id: 'visitor-2' } ); 125 | 126 | var zoo = new Zoo( { visitors: [ 'visitor-1', 'visitor-3' ] } ), 127 | visitors = zoo.get( 'visitors' ); 128 | 129 | equal( visitors.length, 1 ); 130 | 131 | var visitor3 = new Visitor( { id: 'visitor-3' } ); 132 | equal( visitors.length, 2 ); 133 | 134 | zoo.set( 'visitors', [ { name: 'Incognito' } ] ); 135 | equal( visitors.length, 1 ); 136 | 137 | zoo.set( 'visitors', [] ); 138 | equal( visitors.length, 0 ); 139 | 140 | // Handle an array of objects 141 | zoo = new Zoo( { visitors: [ { id: 'visitor-1' }, { id: 'visitor-4' } ] } ); 142 | visitors = zoo.get( 'visitors' ); 143 | 144 | equal( visitors.length, 2 ); 145 | equal( visitors.get( 'visitor-1' ).get( 'name' ), 'Mr. Pink', 'visitor-1 is Mr. Pink' ); 146 | 147 | zoo.set( 'visitors', [ { id: 'visitor-1' }, { id: 'visitor-5' } ] ); 148 | equal( visitors.length, 2 ); 149 | 150 | // Handle an array of models 151 | zoo = new Zoo( { visitors: [ visitor1 ] } ); 152 | visitors = zoo.get( 'visitors' ); 153 | 154 | equal( visitors.length, 1 ); 155 | ok( visitors.first() === visitor1 ); 156 | 157 | zoo.set( 'visitors', [ visitor2 ] ); 158 | equal( visitors.length, 1 ); 159 | ok( visitors.first() === visitor2 ); 160 | 161 | // Handle a Collection 162 | var visitorColl = new Backbone.Relational.Collection( [ visitor1, visitor2 ] ); 163 | zoo = new Zoo( { visitors: visitorColl } ); 164 | visitors = zoo.get( 'visitors' ); 165 | 166 | equal( visitors.length, 2 ); 167 | 168 | zoo.set( 'visitors', false ); 169 | equal( visitors.length, 0 ); 170 | 171 | visitorColl = new Backbone.Relational.Collection( [ visitor2 ] ); 172 | zoo.set( 'visitors', visitorColl ); 173 | ok( visitorColl === zoo.get( 'visitors' ) ); 174 | equal( zoo.get( 'visitors' ).length, 1 ); 175 | }); 176 | 177 | QUnit.test( "On `set`, or creation, handle edge-cases where the server supplies a single object/id", function() { 178 | // Handle single objects 179 | var zoo = new Zoo({ 180 | animals: { id: 'lion-1' } 181 | }); 182 | var animals = zoo.get( 'animals' ); 183 | 184 | equal( animals.length, 1, "There is 1 animal in the zoo" ); 185 | 186 | zoo.set( 'animals', { id: 'lion-2' } ); 187 | equal( animals.length, 1, "There is 1 animal in the zoo" ); 188 | 189 | // Handle single models 190 | var lion3 = new Animal( { id: 'lion-3' } ); 191 | zoo = new Zoo({ 192 | animals: lion3 193 | }); 194 | animals = zoo.get( 'animals' ); 195 | 196 | equal( animals.length, 1, "There is 1 animal in the zoo" ); 197 | 198 | zoo.set( 'animals', null ); 199 | equal( animals.length, 0, "No animals in the zoo" ); 200 | 201 | zoo.set( 'animals', lion3 ); 202 | equal( animals.length, 1, "There is 1 animal in the zoo" ); 203 | 204 | // Handle single ids 205 | zoo = new Zoo({ 206 | animals: 'lion-4' 207 | }); 208 | animals = zoo.get( 'animals' ); 209 | 210 | equal( animals.length, 0, "No animals in the zoo" ); 211 | 212 | var lion4 = new Animal( { id: 'lion-4' } ); 213 | equal( animals.length, 1, "There is 1 animal in the zoo" ); 214 | 215 | zoo.set( 'animals', 'lion-5' ); 216 | equal( animals.length, 0, "No animals in the zoo" ); 217 | 218 | var lion5 = new Animal( { id: 'lion-5' } ); 219 | equal( animals.length, 1, "There is 1 animal in the zoo" ); 220 | 221 | zoo.set( 'animals', null ); 222 | equal( animals.length, 0, "No animals in the zoo" ); 223 | 224 | 225 | zoo = new Zoo({ 226 | animals: 'lion-4' 227 | }); 228 | animals = zoo.get( 'animals' ); 229 | 230 | equal( animals.length, 1, "There is 1 animal in the zoo" ); 231 | 232 | // Bulletproof? 233 | zoo = new Zoo({ 234 | animals: '' 235 | }); 236 | animals = zoo.get( 'animals' ); 237 | 238 | ok( animals instanceof AnimalCollection ); 239 | equal( animals.length, 0, "No animals in the zoo" ); 240 | }); 241 | 242 | QUnit.test( "Setting a custom collection in 'collectionType' uses that collection for instantiation", function() { 243 | var zoo = new Zoo(); 244 | 245 | // Set values so that the relation gets filled 246 | zoo.set({ 247 | animals: [ 248 | { species: 'Lion' }, 249 | { species: 'Zebra' } 250 | ] 251 | }); 252 | 253 | // Check that the animals were created 254 | ok( zoo.get( 'animals' ).at( 0 ).get( 'species' ) === 'Lion' ); 255 | ok( zoo.get( 'animals' ).at( 1 ).get( 'species' ) === 'Zebra' ); 256 | 257 | // Check that the generated collection is of the correct kind 258 | ok( zoo.get( 'animals' ) instanceof AnimalCollection ); 259 | }); 260 | 261 | QUnit.test( "Setting a new collection maintains that collection's current 'models'", function() { 262 | var zoo = new Zoo(); 263 | 264 | var animals = new AnimalCollection([ 265 | { id: 1, species: 'Lion' }, 266 | { id: 2 ,species: 'Zebra' } 267 | ]); 268 | 269 | zoo.set( 'animals', animals ); 270 | 271 | equal( zoo.get( 'animals' ).length, 2 ); 272 | 273 | var newAnimals = new AnimalCollection([ 274 | { id: 2, species: 'Zebra' }, 275 | { id: 3, species: 'Elephant' }, 276 | { id: 4, species: 'Tiger' } 277 | ]); 278 | 279 | zoo.set( 'animals', newAnimals ); 280 | 281 | equal( zoo.get( 'animals' ).length, 3 ); 282 | }); 283 | 284 | QUnit.test( "Models found in 'findRelated' are all added in one go (so 'sort' will only be called once)", function() { 285 | var count = 0, 286 | sort = Backbone.Relational.Collection.prototype.sort; 287 | 288 | Backbone.Relational.Collection.prototype.sort = function() { 289 | count++; 290 | }; 291 | 292 | AnimalCollection.prototype.comparator = $.noop; 293 | 294 | var zoo = new Zoo({ 295 | animals: [ 296 | { id: 1, species: 'Lion' }, 297 | { id: 2 ,species: 'Zebra' } 298 | ] 299 | }); 300 | 301 | equal( count, 1, "Sort is called only once" ); 302 | 303 | Backbone.Relational.Collection.prototype.sort = sort; 304 | delete AnimalCollection.prototype.comparator; 305 | }); 306 | 307 | QUnit.test( "Raw-models set to a hasMany relation do trigger an add event in the underlying Collection with a correct index", function() { 308 | var zoo = new Zoo(); 309 | 310 | var indexes = []; 311 | 312 | zoo.get( 'animals' ).on( 'add', function( model, collection, options ) { 313 | var index = collection.indexOf( model ); 314 | indexes.push(index); 315 | }); 316 | 317 | zoo.set( 'animals', [ 318 | { id : 1, species : 'Lion' }, 319 | { id : 2, species : 'Zebra' } 320 | ]); 321 | 322 | equal( indexes[0], 0, "First item has index 0" ); 323 | equal( indexes[1], 1, "Second item has index 1" ); 324 | }); 325 | 326 | QUnit.test( "Models set to a hasMany relation do trigger an add event in the underlying Collection with a correct index", function() { 327 | var zoo = new Zoo(); 328 | 329 | var indexes = []; 330 | 331 | zoo.get("animals").on("add", function(model, collection, options) { 332 | var index = collection.indexOf(model); 333 | indexes.push(index); 334 | }); 335 | 336 | zoo.set("animals", [ 337 | new Animal({ id : 1, species : 'Lion' }), 338 | new Animal({ id : 2, species : 'Zebra'}) 339 | ]); 340 | 341 | equal( indexes[0], 0, "First item has index 0" ); 342 | equal( indexes[1], 1, "Second item has index 1" ); 343 | }); 344 | 345 | 346 | QUnit.test( "Sort event should be fired after the add event that caused it, even when using 'set'", function() { 347 | var zoo = new Zoo(); 348 | var animals = zoo.get('animals'); 349 | var events = []; 350 | 351 | animals.comparator = 'id'; 352 | 353 | animals.on('add', function() { events.push('add'); }); 354 | animals.on('sort', function() { events.push('sort'); }); 355 | 356 | zoo.set('animals' , [ 357 | {id : 'lion-2'}, 358 | {id : 'lion-1'} 359 | ]); 360 | 361 | equal(animals.at(0).id, 'lion-1'); 362 | deepEqual(events, ['add', 'add', 'sort']); 363 | }); 364 | 365 | 366 | QUnit.test( "The 'collectionKey' options is used to create references on generated Collections back to its RelationalModel", function() { 367 | var zoo = new Zoo({ 368 | animals: [ 'lion-1', 'zebra-1' ] 369 | }); 370 | 371 | equal( zoo.get( 'animals' ).livesIn, zoo ); 372 | equal( zoo.get( 'animals' ).zoo, undefined ); 373 | 374 | 375 | var FarmAnimal = Backbone.Relational.Model.extend(); 376 | var Barn = Backbone.Relational.Model.extend({ 377 | relations: [{ 378 | type: Backbone.Relational.HasMany, 379 | key: 'animals', 380 | relatedModel: FarmAnimal, 381 | collectionKey: 'barn', 382 | reverseRelation: { 383 | key: 'livesIn', 384 | includeInJSON: 'id' 385 | } 386 | }] 387 | }); 388 | var barn = new Barn({ 389 | animals: [ 'chicken-1', 'cow-1' ] 390 | }); 391 | 392 | equal( barn.get( 'animals' ).livesIn, undefined ); 393 | equal( barn.get( 'animals' ).barn, barn ); 394 | 395 | FarmAnimal = Backbone.Relational.Model.extend(); 396 | var BarnNoKey = Backbone.Relational.Model.extend({ 397 | relations: [{ 398 | type: Backbone.Relational.HasMany, 399 | key: 'animals', 400 | relatedModel: FarmAnimal, 401 | collectionKey: false, 402 | reverseRelation: { 403 | key: 'livesIn', 404 | includeInJSON: 'id' 405 | } 406 | }] 407 | }); 408 | var barnNoKey = new BarnNoKey({ 409 | animals: [ 'chicken-1', 'cow-1' ] 410 | }); 411 | 412 | equal( barnNoKey.get( 'animals' ).livesIn, undefined ); 413 | equal( barnNoKey.get( 'animals' ).barn, undefined ); 414 | }); 415 | 416 | QUnit.test( "Polymorhpic relations", function() { 417 | var Location = Backbone.Relational.Model.extend(); 418 | 419 | var Locatable = Backbone.Relational.Model.extend({ 420 | relations: [ 421 | { 422 | key: 'locations', 423 | type: 'HasMany', 424 | relatedModel: Location, 425 | reverseRelation: { 426 | key: 'locatable' 427 | } 428 | } 429 | ] 430 | }); 431 | 432 | var FirstLocatable = Locatable.extend(); 433 | var SecondLocatable = Locatable.extend(); 434 | 435 | var firstLocatable = new FirstLocatable(); 436 | var secondLocatable = new SecondLocatable(); 437 | 438 | var firstLocation = new Location( { id: 1, locatable: firstLocatable } ); 439 | var secondLocation = new Location( { id: 2, locatable: secondLocatable } ); 440 | 441 | ok( firstLocatable.get( 'locations' ).at( 0 ) === firstLocation ); 442 | ok( firstLocatable.get( 'locations' ).at( 0 ).get( 'locatable' ) === firstLocatable ); 443 | 444 | ok( secondLocatable.get( 'locations' ).at( 0 ) === secondLocation ); 445 | ok( secondLocatable.get( 'locations' ).at( 0 ).get( 'locatable' ) === secondLocatable ); 446 | }); 447 | 448 | QUnit.test( "Cloned instances of persisted models should not be added to any existing collections", function() { 449 | var addedModels = 0; 450 | 451 | var zoo = new window.Zoo({ 452 | visitors : [ { name : "Incognito" } ] 453 | }); 454 | 455 | var visitor = new window.Visitor(); 456 | 457 | zoo.get( 'visitors' ).on( 'add', function( model, coll ) { 458 | addedModels++; 459 | }); 460 | 461 | visitor.clone(); 462 | 463 | equal( addedModels, 0, "A new visitor should not be forced to go to the zoo!" ); 464 | }); 465 | -------------------------------------------------------------------------------- /test/reverse-relations.js: -------------------------------------------------------------------------------- 1 | QUnit.module( "Reverse relations", { setup: require('./setup/data') } ); 2 | 3 | QUnit.test( "Add and remove", function() { 4 | equal( ourHouse.get( 'occupants' ).length, 1, "ourHouse has 1 occupant" ); 5 | equal( person1.get( 'livesIn' ), null, "Person 1 doesn't live anywhere" ); 6 | 7 | ourHouse.get( 'occupants' ).add( person1 ); 8 | 9 | equal( ourHouse.get( 'occupants' ).length, 2, "Our House has 2 occupants" ); 10 | equal( person1.get( 'livesIn' ) && person1.get('livesIn').id, ourHouse.id, "Person 1 lives in ourHouse" ); 11 | 12 | person1.set( { 'livesIn': theirHouse } ); 13 | 14 | equal( theirHouse.get( 'occupants' ).length, 1, "theirHouse has 1 occupant" ); 15 | equal( ourHouse.get( 'occupants' ).length, 1, "ourHouse has 1 occupant" ); 16 | equal( person1.get( 'livesIn' ) && person1.get('livesIn').id, theirHouse.id, "Person 1 lives in theirHouse" ); 17 | }); 18 | 19 | QUnit.test( "Destroy removes models from reverse relations", function() { 20 | var zoo = new Zoo( { id:1, animals: [ 2, 3, 4 ] } ); 21 | 22 | var rhino = new Animal( { id: 2, species: 'rhino' } ); 23 | var baboon = new Animal( { id: 3, species: 'baboon' } ); 24 | var hippo = new Animal( { id: 4, species: 'hippo' } ); 25 | 26 | ok( zoo.get( 'animals' ).length === 3 ); 27 | 28 | rhino.destroy(); 29 | 30 | ok( zoo.get( 'animals' ).length === 2 ); 31 | ok( zoo.get( 'animals' ).get( baboon ) === baboon ); 32 | ok( !rhino.get( 'zoo' ) ); 33 | 34 | zoo.get( 'animals' ).remove( hippo ); 35 | 36 | ok( zoo.get( 'animals' ).length === 1 ); 37 | ok( !hippo.get( 'zoo' ) ); 38 | 39 | zoo.destroy(); 40 | 41 | ok( zoo.get( 'animals' ).length === 0 ); 42 | ok( !baboon.get( 'zoo' ) ); 43 | }); 44 | 45 | QUnit.test( "HasOne relations to self (tree stucture)", function() { 46 | var child1 = new Node({ id: '2', parent: '1', name: 'First child' }); 47 | var parent = new Node({ id: '1', name: 'Parent' }); 48 | var child2 = new Node({ id: '3', parent: '1', name: 'Second child' }); 49 | 50 | equal( parent.get( 'children' ).length, 2 ); 51 | ok( parent.get( 'children' ).include( child1 ) ); 52 | ok( parent.get( 'children' ).include( child2 ) ); 53 | 54 | ok( child1.get( 'parent' ) === parent ); 55 | equal( child1.get( 'children' ).length, 0 ); 56 | 57 | ok( child2.get( 'parent' ) === parent ); 58 | equal( child2.get( 'children' ).length, 0 ); 59 | }); 60 | 61 | QUnit.test( "Models referencing each other in the same relation", function() { 62 | var parent = new Node({ id: 1 }); 63 | var child = new Node({ id: 2 }); 64 | 65 | child.set( 'parent', parent ); 66 | parent.save( { 'parent': child } ); 67 | 68 | ok( parent.get( 'parent' ) === child ); 69 | ok( child.get( 'parent' ) === parent ); 70 | }); 71 | 72 | QUnit.test( "HasMany relations to self (tree structure)", function() { 73 | var child1 = new Node({ id: '2', name: 'First child' }); 74 | var parent = new Node({ id: '1', children: [ '2', '3' ], name: 'Parent' }); 75 | var child2 = new Node({ id: '3', name: 'Second child' }); 76 | 77 | equal( parent.get( 'children' ).length, 2 ); 78 | ok( parent.get( 'children' ).include( child1 ) ); 79 | ok( parent.get( 'children' ).include( child2 ) ); 80 | 81 | ok( child1.get( 'parent' ) === parent ); 82 | equal( child1.get( 'children' ).length, 0 ); 83 | 84 | ok( child2.get( 'parent' ) === parent ); 85 | equal( child2.get( 'children' ).length, 0 ); 86 | }); 87 | 88 | QUnit.test( "HasOne relations to self (cycle, directed graph structure)", function() { 89 | var node1 = new Node({ id: '1', parent: '3', name: 'First node' }); 90 | var node2 = new Node({ id: '2', parent: '1', name: 'Second node' }); 91 | var node3 = new Node({ id: '3', parent: '2', name: 'Third node' }); 92 | 93 | ok( node1.get( 'parent' ) === node3 ); 94 | equal( node1.get( 'children' ).length, 1 ); 95 | ok( node1.get( 'children' ).at(0) === node2 ); 96 | 97 | ok( node2.get( 'parent' ) === node1 ); 98 | equal( node2.get( 'children' ).length, 1 ); 99 | ok( node2.get( 'children' ).at(0) === node3 ); 100 | 101 | ok( node3.get( 'parent' ) === node2 ); 102 | equal( node3.get( 'children' ).length, 1 ); 103 | ok( node3.get( 'children' ).at(0) === node1 ); 104 | }); 105 | 106 | QUnit.test( "New objects (no 'id' yet) have working relations", function() { 107 | var person = new Person({ 108 | name: 'Remi' 109 | }); 110 | 111 | person.set( { user: { login: '1', email: '1' } } ); 112 | var user1 = person.get( 'user' ); 113 | 114 | ok( user1 instanceof User, "User created on Person" ); 115 | equal( user1.get('login'), '1', "person.user is the correct User" ); 116 | 117 | var user2 = new User({ 118 | login: '2', 119 | email: '2' 120 | }); 121 | 122 | ok( user2.get( 'person' ) === null, "'user' doesn't belong to a 'person' yet" ); 123 | 124 | person.set( { user: user2 } ); 125 | 126 | ok( user1.get( 'person' ) === null ); 127 | ok( person.get( 'user' ) === user2 ); 128 | ok( user2.get( 'person' ) === person ); 129 | 130 | person2.set( { user: user2 } ); 131 | 132 | ok( person.get( 'user' ) === null ); 133 | ok( person2.get( 'user' ) === user2 ); 134 | ok( user2.get( 'person' ) === person2 ); 135 | }); 136 | 137 | QUnit.test( "'Save' objects (performing 'set' multiple times without and with id)", 4, function() { 138 | person3 139 | .on( 'add:jobs', function( model, coll ) { 140 | console.log('got here 1'); 141 | var company = model.get('company'); 142 | ok( company instanceof Company && company.get('ceo').get('name') === 'Lunar boy' && model.get('person') === person3, 143 | "add:jobs: Both Person and Company are set on the Job instance once the event gets fired" ); 144 | }) 145 | .on( 'remove:jobs', function( model, coll ) { 146 | console.log('got here 2'); 147 | ok( false, "remove:jobs: 'person3' should not lose his job" ); 148 | }); 149 | 150 | // Create Models from an object. Should trigger `add:jobs` on `person3` 151 | var company = new Company({ 152 | name: 'Luna Corp.', 153 | ceo: { 154 | name: 'Lunar boy' 155 | }, 156 | employees: [ { person: 'person-3' } ] 157 | }); 158 | 159 | company 160 | .on( 'add:employees', function( model, coll ) { 161 | console.log('got here 3'); 162 | var company = model.get('company'); 163 | ok( company instanceof Company && company.get('ceo').get('name') === 'Lunar boy' && model.get('person') === person3, 164 | "add:employees: Both Person and Company are set on the Company instance once the event gets fired" ); 165 | }) 166 | .on( 'remove:employees', function( model, coll ) { 167 | console.log('got here 4'); 168 | ok( true, "'remove:employees: person3' should lose a job once" ); 169 | }); 170 | 171 | // Backbone.save executes "model.set(model.parse(resp), options)". Set a full map over object, but now with ids. 172 | // Should trigger `remove:employees`, `add:employees`, and `add:jobs` 173 | company.set({ 174 | id: 'company-3', 175 | name: 'Big Corp.', 176 | ceo: { 177 | id: 'person-4', 178 | name: 'Lunar boy', 179 | resource_uri: 'person-4' 180 | }, 181 | employees: [ { id: 'job-1', person: 'person-3', resource_uri: 'job-1' } ], 182 | resource_uri: 'company-3' 183 | }); 184 | 185 | // This should not trigger additional `add`/`remove` events 186 | company.set({ 187 | employees: [ 'job-1' ] 188 | }); 189 | }); 190 | 191 | QUnit.test( "Set the same value a couple of time, by 'id' and object", function() { 192 | person1.set( { likesALot: 'person-2' } ); 193 | person1.set( { likesALot: person2 } ); 194 | 195 | ok( person1.get('likesALot') === person2 ); 196 | ok( person2.get('likedALotBy' ) === person1 ); 197 | 198 | person1.set( { likesALot: 'person-2' } ); 199 | 200 | ok( person1.get('likesALot') === person2 ); 201 | ok( person2.get('likedALotBy' ) === person1 ); 202 | }); 203 | 204 | QUnit.test( "Numerical keys", function() { 205 | var child1 = new Node({ id: 2, name: 'First child' }); 206 | var parent = new Node({ id: 1, children: [2, 3], name: 'Parent' }); 207 | var child2 = new Node({ id: 3, name: 'Second child' }); 208 | 209 | equal( parent.get('children').length, 2 ); 210 | ok( parent.get('children').include( child1 ) ); 211 | ok( parent.get('children').include( child2 ) ); 212 | 213 | ok( child1.get('parent') === parent ); 214 | equal( child1.get('children').length, 0 ); 215 | 216 | ok( child2.get('parent') === parent ); 217 | equal( child2.get('children').length, 0 ); 218 | }); 219 | 220 | QUnit.test( "Relations that use refs to other models (instead of keys)", function() { 221 | var child1 = new Node({ id: 2, name: 'First child' }); 222 | var parent = new Node({ id: 1, children: [child1, 3], name: 'Parent' }); 223 | var child2 = new Node({ id: 3, name: 'Second child' }); 224 | 225 | ok( child1.get('parent') === parent ); 226 | equal( child1.get('children').length, 0 ); 227 | 228 | equal( parent.get('children').length, 2 ); 229 | ok( parent.get('children').include( child1 ) ); 230 | ok( parent.get('children').include( child2 ) ); 231 | 232 | var child3 = new Node({ id: 4, parent: parent, name: 'Second child' }); 233 | 234 | equal( parent.get('children').length, 3 ); 235 | ok( parent.get('children').include( child3 ) ); 236 | 237 | ok( child3.get('parent') === parent ); 238 | equal( child3.get('children').length, 0 ); 239 | }); 240 | 241 | QUnit.test( "Add an already existing model (reverseRelation shouldn't exist yet) to a relation as a hash", function() { 242 | // This test caused a race condition to surface: 243 | // The 'relation's constructor initializes the 'reverseRelation', which called 'relation.addRelated' in it's 'initialize'. 244 | // However, 'relation's 'initialize' has not been executed yet, so it doesn't have a 'related' collection yet. 245 | var Properties = Backbone.Relational.Model.extend({}); 246 | var View = Backbone.Relational.Model.extend({ 247 | relations: [ 248 | { 249 | type: Backbone.Relational.HasMany, 250 | key: 'properties', 251 | relatedModel: Properties, 252 | reverseRelation: { 253 | type: Backbone.Relational.HasOne, 254 | key: 'view' 255 | } 256 | } 257 | ] 258 | }); 259 | 260 | var props = new Properties( { id: 1, key: 'width', value: '300px', view: 1 } ); 261 | var view = new View({ 262 | id: 1, 263 | properties: [ { id: 1, key: 'width', value: '300px', view: 1 } ] 264 | }); 265 | 266 | ok( props.get( 'view' ) === view ); 267 | ok( view.get( 'properties' ).include( props ) ); 268 | }); 269 | 270 | QUnit.test( "Reverse relations are found for models that have not been instantiated and use .extend()", function() { 271 | var View = Backbone.Relational.Model.extend({ }); 272 | var Property = Backbone.Relational.Model.extend({ 273 | relations: [{ 274 | type: Backbone.Relational.HasOne, 275 | key: 'view', 276 | relatedModel: View, 277 | reverseRelation: { 278 | type: Backbone.Relational.HasMany, 279 | key: 'properties' 280 | } 281 | }] 282 | }); 283 | 284 | var view = new View({ 285 | id: 1, 286 | properties: [ { id: 1, key: 'width', value: '300px' } ] 287 | }); 288 | 289 | ok( view.get( 'properties' ) instanceof Backbone.Relational.Collection ); 290 | }); 291 | 292 | QUnit.test( "Reverse relations found for models that have not been instantiated and run .setup() manually", function() { 293 | // Generated from CoffeeScript code: 294 | // class View extends Backbone.Relational.Model 295 | // 296 | // View.setup() 297 | // 298 | // class Property extends Backbone.Relational.Model 299 | // relations: [ 300 | // type: Backbone.Relational.HasOne 301 | // key: 'view' 302 | // relatedModel: View 303 | // reverseRelation: 304 | // type: Backbone.Relational.HasMany 305 | // key: 'properties' 306 | // ] 307 | // 308 | // Property.setup() 309 | 310 | var Property, View, 311 | __hasProp = {}.hasOwnProperty, 312 | __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor; child.__super__ = parent.prototype; return child; }; 313 | 314 | View = ( function( _super ) { 315 | __extends(View, _super); 316 | 317 | View.name = 'View'; 318 | 319 | function View() { 320 | return View.__super__.constructor.apply( this, arguments ); 321 | } 322 | 323 | return View; 324 | })( Backbone.Relational.Model ); 325 | 326 | View.setup(); 327 | 328 | Property = (function(_super) { 329 | __extends(Property, _super); 330 | 331 | Property.name = 'Property'; 332 | 333 | function Property() { 334 | return Property.__super__.constructor.apply(this, arguments); 335 | } 336 | 337 | Property.prototype.relations = [{ 338 | type: Backbone.Relational.HasOne, 339 | key: 'view', 340 | relatedModel: View, 341 | reverseRelation: { 342 | type: Backbone.Relational.HasMany, 343 | key: 'properties' 344 | } 345 | }]; 346 | 347 | return Property; 348 | })(Backbone.Relational.Model); 349 | 350 | Property.setup(); 351 | 352 | var view = new View({ 353 | id: 1, 354 | properties: [ { id: 1, key: 'width', value: '300px' } ] 355 | }); 356 | 357 | ok( view.get( 'properties' ) instanceof Backbone.Relational.Collection ); 358 | }); 359 | 360 | QUnit.test( "ReverseRelations are applied retroactively", function() { 361 | // Use brand new Model types, so we can be sure we don't have any reverse relations cached from previous tests 362 | var NewUser = Backbone.Relational.Model.extend({}); 363 | var NewPerson = Backbone.Relational.Model.extend({ 364 | relations: [{ 365 | type: Backbone.Relational.HasOne, 366 | key: 'user', 367 | relatedModel: NewUser, 368 | reverseRelation: { 369 | type: Backbone.Relational.HasOne, 370 | key: 'person' 371 | } 372 | }] 373 | }); 374 | 375 | var user = new NewUser( { id: 'newuser-1' } ); 376 | //var user2 = new NewUser( { id: 'newuser-2', person: 'newperson-1' } ); 377 | var person = new NewPerson( { id: 'newperson-1', user: user } ); 378 | 379 | ok( person.get('user') === user ); 380 | ok( user.get('person') === person ); 381 | //console.log( person, user ); 382 | }); 383 | 384 | QUnit.test( "ReverseRelations are applied retroactively (2)", function() { 385 | var models = {}; 386 | Backbone.Relational.store.addModelScope( models ); 387 | 388 | // Use brand new Model types, so we can be sure we don't have any reverse relations cached from previous tests 389 | models.NewPerson = Backbone.Relational.Model.extend({ 390 | relations: [{ 391 | type: Backbone.Relational.HasOne, 392 | key: 'user', 393 | relatedModel: 'NewUser', 394 | reverseRelation: { 395 | type: Backbone.Relational.HasOne, 396 | key: 'person' 397 | } 398 | }] 399 | }); 400 | models.NewUser = Backbone.Relational.Model.extend({}); 401 | 402 | var user = new models.NewUser( { id: 'newuser-1', person: { id: 'newperson-1' } } ); 403 | 404 | equal( user.getRelations().length, 1 ); 405 | ok( user.get( 'person' ) instanceof models.NewPerson ); 406 | }); 407 | 408 | QUnit.test( "Deep reverse relation starting from a collection", function() { 409 | var nodes = new NodeList([ 410 | { 411 | id: 1, 412 | children: [ 413 | { 414 | id: 2, 415 | children: [ 416 | { 417 | id: 3, 418 | children: [ 1 ] 419 | } 420 | ] 421 | } 422 | ] 423 | } 424 | ]); 425 | 426 | var parent = nodes.first(); 427 | ok( parent, 'first item accessible after resetting collection' ); 428 | 429 | ok( parent.collection === nodes, '`parent.collection` is set to `nodes`' ); 430 | 431 | var child = parent.get( 'children' ).first(); 432 | ok( child, '`child` can be retrieved from `parent`' ); 433 | ok( child.get( 'parent' ), 'reverse relation from `child` to `parent` works'); 434 | 435 | var grandchild = child.get( 'children' ).first(); 436 | ok( grandchild, '`grandchild` can be retrieved from `child`' ); 437 | 438 | ok( grandchild.get( 'parent' ), 'reverse relation from `grandchild` to `child` works'); 439 | 440 | ok( grandchild.get( 'children' ).first() === parent, 'reverse relation from `grandchild` to `parent` works'); 441 | ok( parent.get( 'parent' ) === grandchild, 'circular reference from `grandchild` to `parent` works' ); 442 | }); 443 | 444 | QUnit.test( "Deep reverse relation starting from a collection, with existing model", function() { 445 | new Node( { id: 1 } ); 446 | 447 | var nodes = new NodeList(); 448 | nodes.set([ 449 | { 450 | id: 1, 451 | children: [ 452 | { 453 | id: 2, 454 | children: [ 455 | { 456 | id: 3, 457 | children: [ 1 ] 458 | } 459 | ] 460 | } 461 | ] 462 | } 463 | ]); 464 | 465 | var parent = nodes.first(); 466 | ok( parent && parent.id === 1, 'first item accessible after resetting collection' ); 467 | 468 | var child = parent.get( 'children' ).first(); 469 | ok( child, '`child` can be retrieved from `parent`' ); 470 | ok( child.get( 'parent' ), 'reverse relation from `child` to `parent` works'); 471 | 472 | var grandchild = child.get( 'children' ).first(); 473 | ok( grandchild, '`grandchild` can be retrieved from `child`' ); 474 | 475 | ok( grandchild.get( 'parent' ), 'reverse relation from `grandchild` to `child` works'); 476 | 477 | ok( grandchild.get( 'children' ).first() === parent, 'reverse relation from `grandchild` to `parent` works'); 478 | ok( parent.get( 'parent' ) === grandchild, 'circular reference from `grandchild` to `parent` works' ); 479 | }); 480 | -------------------------------------------------------------------------------- /test/relation.js: -------------------------------------------------------------------------------- 1 | QUnit.module( "Backbone.Relational.Relation options", { setup: require('./setup/data') } ); 2 | 3 | QUnit.test( "`includeInJSON` (Person to JSON)", function() { 4 | var json = person1.toJSON(); 5 | equal( json.user_id, 'user-1', "The value of 'user_id' is the user's id (not an object, since 'includeInJSON' is set to the idAttribute)" ); 6 | ok ( json.likesALot instanceof Object, "The value of 'likesALot' is an object ('includeInJSON' is 'true')" ); 7 | equal( json.likesALot.likesALot, 'person-1', "Person is serialized only once" ); 8 | 9 | json = person1.get( 'user' ).toJSON(); 10 | equal( json.person, 'boy', "The value of 'person' is the person's name (`includeInJSON` is set to 'name')" ); 11 | 12 | json = person2.toJSON(); 13 | ok( person2.get('livesIn') instanceof House, "'person2' has a 'livesIn' relation" ); 14 | equal( json.livesIn, undefined , "The value of 'livesIn' is not serialized (`includeInJSON` is 'false')" ); 15 | 16 | json = person3.toJSON(); 17 | ok( json.user_id === null, "The value of 'user_id' is null"); 18 | ok( json.likesALot === null, "The value of 'likesALot' is null"); 19 | }); 20 | 21 | QUnit.test( "`includeInJSON` (Zoo to JSON)", function() { 22 | var zoo = new Zoo({ 23 | id: 0, 24 | name: 'Artis', 25 | city: 'Amsterdam', 26 | animals: [ 27 | new Animal( { id: 1, species: 'bear', name: 'Baloo' } ), 28 | new Animal( { id: 2, species: 'tiger', name: 'Shere Khan' } ) 29 | ] 30 | }); 31 | 32 | var jsonZoo = zoo.toJSON(), 33 | jsonBear = jsonZoo.animals[ 0 ]; 34 | 35 | ok( _.isArray( jsonZoo.animals ), "animals is an Array" ); 36 | equal( jsonZoo.animals.length, 2 ); 37 | equal( jsonBear.id, 1, "animal's id has been included in the JSON" ); 38 | equal( jsonBear.species, 'bear', "animal's species has been included in the JSON" ); 39 | ok( !jsonBear.name, "animal's name has not been included in the JSON" ); 40 | 41 | var tiger = zoo.get( 'animals' ).get( 1 ), 42 | jsonTiger = tiger.toJSON(); 43 | 44 | ok( _.isObject( jsonTiger.livesIn ) && !_.isArray( jsonTiger.livesIn ), "zoo is an Object" ); 45 | equal( jsonTiger.livesIn.id, 0, "zoo.id is included in the JSON" ); 46 | equal( jsonTiger.livesIn.name, 'Artis', "zoo.name is included in the JSON" ); 47 | ok( !jsonTiger.livesIn.city, "zoo.city is not included in the JSON" ); 48 | }); 49 | 50 | QUnit.test( "'createModels' is false", function() { 51 | var NewUser = Backbone.Relational.Model.extend({}); 52 | var NewPerson = Backbone.Relational.Model.extend({ 53 | relations: [{ 54 | type: Backbone.Relational.HasOne, 55 | key: 'user', 56 | relatedModel: NewUser, 57 | createModels: false 58 | }] 59 | }); 60 | 61 | var person = new NewPerson({ 62 | id: 'newperson-1', 63 | resource_uri: 'newperson-1', 64 | user: { id: 'newuser-1', resource_uri: 'newuser-1' } 65 | }); 66 | 67 | ok( person.get( 'user' ) == null ); 68 | 69 | var user = new NewUser( { id: 'newuser-1', name: 'SuperUser' } ); 70 | 71 | ok( person.get( 'user' ) === user ); 72 | // Old data gets overwritten by the explicitly created user, since a model was never created from the old data 73 | ok( person.get( 'user' ).get( 'resource_uri' ) == null ); 74 | }); 75 | 76 | QUnit.test( "Relations load from both `keySource` and `key`", function() { 77 | var Property = Backbone.Relational.Model.extend({ 78 | idAttribute: 'property_id' 79 | }); 80 | var View = Backbone.Relational.Model.extend({ 81 | idAttribute: 'id', 82 | 83 | relations: [{ 84 | type: Backbone.Relational.HasMany, 85 | key: 'properties', 86 | keySource: 'property_ids', 87 | relatedModel: Property, 88 | reverseRelation: { 89 | key: 'view', 90 | keySource: 'view_id' 91 | } 92 | }] 93 | }); 94 | 95 | var property1 = new Property({ 96 | property_id: 1, 97 | key: 'width', 98 | value: 500, 99 | view_id: 5 100 | }); 101 | 102 | var view = new View({ 103 | id: 5, 104 | property_ids: [ 2 ] 105 | }); 106 | 107 | var property2 = new Property({ 108 | property_id: 2, 109 | key: 'height', 110 | value: 400 111 | }); 112 | 113 | // The values from view.property_ids should be loaded into view.properties 114 | ok( view.get( 'properties' ) && view.get( 'properties' ).length === 2, "'view' has two 'properties'" ); 115 | ok( typeof view.get( 'property_ids' ) === 'undefined', "'view' does not have 'property_ids'" ); 116 | 117 | view.set( 'properties', [ property1, property2 ] ); 118 | ok( view.get( 'properties' ) && view.get( 'properties' ).length === 2, "'view' has two 'properties'" ); 119 | 120 | view.set( 'property_ids', [ 1, 2 ] ); 121 | ok( view.get( 'properties' ) && view.get( 'properties' ).length === 2, "'view' has two 'properties'" ); 122 | }); 123 | 124 | QUnit.test( "`keySource` is emptied after a set, doesn't get confused by `unset`", function() { 125 | var SubModel = Backbone.Relational.Model.extend(); 126 | 127 | var Model = Backbone.Relational.Model.extend({ 128 | relations: [{ 129 | type: Backbone.Relational.HasOne, 130 | key: 'submodel', 131 | keySource: 'sub_data', 132 | relatedModel: SubModel 133 | }] 134 | }); 135 | 136 | var inst = new Model( {'id': 123} ); 137 | 138 | // `set` may be called from fetch 139 | inst.set({ 140 | 'id': 123, 141 | 'some_field': 'some_value', 142 | 'sub_data': { 143 | 'id': 321, 144 | 'key': 'value' 145 | }, 146 | 'to_unset': 'unset value' 147 | }); 148 | 149 | ok( inst.get('submodel').get('key') === 'value', "value of submodule.key should be 'value'" ); 150 | inst.set( { 'to_unset': '' }, { 'unset': true } ); 151 | ok( inst.get('submodel').get('key') === 'value', "after unset value of submodule.key should be still 'value'" ); 152 | 153 | ok( typeof inst.get('sub_data') === 'undefined', "keySource field should be removed from model" ); 154 | ok( typeof inst.get('submodel') !== 'undefined', "key field should be added..." ); 155 | ok( inst.get('submodel') instanceof SubModel, "... and should be model instance" ); 156 | 157 | // set called from fetch 158 | inst.set({ 159 | 'sub_data': { 160 | 'id': 321, 161 | 'key': 'value2' 162 | } 163 | }); 164 | 165 | ok( typeof inst.get('sub_data') === 'undefined', "keySource field should be removed from model" ); 166 | ok( typeof inst.get('submodel') !== 'undefined', "key field should be present..." ); 167 | ok( inst.get('submodel').get('key') === 'value2', "... and should be updated" ); 168 | }); 169 | 170 | QUnit.test( "'keyDestination' saves to 'key'", function() { 171 | var Property = Backbone.Relational.Model.extend({ 172 | idAttribute: 'property_id' 173 | }); 174 | var View = Backbone.Relational.Model.extend({ 175 | idAttribute: 'id', 176 | 177 | relations: [{ 178 | type: Backbone.Relational.HasMany, 179 | key: 'properties', 180 | keyDestination: 'properties_attributes', 181 | relatedModel: Property, 182 | reverseRelation: { 183 | key: 'view', 184 | keyDestination: 'view_attributes', 185 | includeInJSON: true 186 | } 187 | }] 188 | }); 189 | 190 | var property1 = new Property({ 191 | property_id: 1, 192 | key: 'width', 193 | value: 500, 194 | view: 5 195 | }); 196 | 197 | var view = new View({ 198 | id: 5, 199 | properties: [ 2 ] 200 | }); 201 | 202 | var property2 = new Property({ 203 | property_id: 2, 204 | key: 'height', 205 | value: 400 206 | }); 207 | 208 | var viewJSON = view.toJSON(); 209 | ok( viewJSON.properties_attributes && viewJSON.properties_attributes.length === 2, "'viewJSON' has two 'properties_attributes'" ); 210 | ok( typeof viewJSON.properties === 'undefined', "'viewJSON' does not have 'properties'" ); 211 | }); 212 | 213 | QUnit.test( "'collectionOptions' sets the options on the created HasMany Collections", function() { 214 | var shop = new Shop({ id: 1 }); 215 | equal( shop.get( 'customers' ).url, 'shop/' + shop.id + '/customers/' ); 216 | }); 217 | 218 | QUnit.test( "`parse` with deeply nested relations", function() { 219 | var collParseCalled = 0, 220 | modelParseCalled = 0; 221 | 222 | var Job = Backbone.Relational.Model.extend({}); 223 | 224 | var JobCollection = Backbone.Relational.Collection.extend({ 225 | model: Job, 226 | 227 | parse: function( resp, options ) { 228 | collParseCalled++; 229 | return resp.data || resp; 230 | } 231 | }); 232 | 233 | var Company = Backbone.Relational.Model.extend({ 234 | relations: [{ 235 | type: 'HasMany', 236 | key: 'employees', 237 | parse: true, 238 | relatedModel: Job, 239 | collectionType: JobCollection, 240 | reverseRelation: { 241 | key: 'company' 242 | } 243 | }] 244 | }); 245 | 246 | var Person = Backbone.Relational.Model.extend({ 247 | relations: [{ 248 | type: 'HasMany', 249 | key: 'jobs', 250 | parse: true, 251 | relatedModel: Job, 252 | collectionType: JobCollection, 253 | reverseRelation: { 254 | key: 'person', 255 | parse: false 256 | } 257 | }], 258 | 259 | parse: function( resp, options ) { 260 | modelParseCalled++; 261 | var data = _.clone( resp.model ); 262 | data.id = data.id.uri; 263 | return data; 264 | } 265 | }); 266 | 267 | Company.prototype.parse = Job.prototype.parse = function( resp, options ) { 268 | modelParseCalled++; 269 | var data = _.clone( resp.model ); 270 | data.id = data.id.uri; 271 | return data; 272 | }; 273 | 274 | var data = { 275 | model: { 276 | id: { uri: 'c1' }, 277 | employees: [ 278 | { 279 | model: { 280 | id: { uri: 'e1' }, 281 | person: { 282 | /*model: { 283 | id: { uri: 'p1' }, 284 | jobs: [ 'e1', { model: { id: { uri: 'e3' } } } ] 285 | }*/ 286 | id: 'p1', 287 | jobs: [ 'e1', { model: { id: { uri: 'e3' } } } ] 288 | } 289 | } 290 | }, 291 | { 292 | model: { 293 | id: { uri: 'e2' }, 294 | person: { 295 | id: 'p2' 296 | /*model: { 297 | id: { uri: 'p2' } 298 | }*/ 299 | } 300 | } 301 | } 302 | ] 303 | } 304 | }; 305 | 306 | var company = new Company( data, { parse: true } ), 307 | employees = company.get( 'employees' ), 308 | job = employees.first(), 309 | person = job.get( 'person' ); 310 | 311 | ok( job && job.id === 'e1', 'job exists' ); 312 | ok( person && person.id === 'p1', 'person exists' ); 313 | 314 | ok( modelParseCalled === 4, 'model.parse called 4 times? ' + modelParseCalled ); 315 | ok( collParseCalled === 0, 'coll.parse called 0 times? ' + collParseCalled ); 316 | }); 317 | 318 | QUnit.module( "Backbone.Relational.Relation preconditions", { setup: require('./setup/setup').reset } ); 319 | 320 | 321 | QUnit.test( "'type', 'key', 'relatedModel' are required properties", function() { 322 | var Properties = Backbone.Relational.Model.extend({}); 323 | var View = Backbone.Relational.Model.extend({ 324 | relations: [ 325 | { 326 | key: 'listProperties', 327 | relatedModel: Properties 328 | } 329 | ] 330 | }); 331 | 332 | var view = new View(); 333 | ok( _.size( view._relations ) === 0 ); 334 | ok( view.getRelations().length === 0 ); 335 | 336 | View = Backbone.Relational.Model.extend({ 337 | relations: [ 338 | { 339 | type: Backbone.Relational.HasOne, 340 | relatedModel: Properties 341 | } 342 | ] 343 | }); 344 | 345 | view = new View(); 346 | ok( _.size( view._relations ) === 0 ); 347 | 348 | View = Backbone.Relational.Model.extend({ 349 | relations: [ 350 | { 351 | type: Backbone.Relational.HasOne, 352 | key: 'parentView' 353 | } 354 | ] 355 | }); 356 | 357 | view = new View(); 358 | ok( _.size( view._relations ) === 1 ); 359 | ok( view.getRelation( 'parentView' ).relatedModel === View, "No 'relatedModel' makes it self-referential" ); 360 | }); 361 | 362 | QUnit.test( "'type' can be a string or an object reference", function() { 363 | var Properties = Backbone.Relational.Model.extend({}); 364 | var View = Backbone.Relational.Model.extend({ 365 | relations: [ 366 | { 367 | type: 'Backbone.Relational.HasOne', 368 | key: 'listProperties', 369 | relatedModel: Properties 370 | } 371 | ] 372 | }); 373 | 374 | var view = new View(); 375 | ok( _.size( view._relations ) === 1 ); 376 | 377 | View = Backbone.Relational.Model.extend({ 378 | relations: [ 379 | { 380 | type: 'HasOne', 381 | key: 'listProperties', 382 | relatedModel: Properties 383 | } 384 | ] 385 | }); 386 | 387 | view = new View(); 388 | ok( _.size( view._relations ) === 1 ); 389 | 390 | View = Backbone.Relational.Model.extend({ 391 | relations: [ 392 | { 393 | type: Backbone.Relational.HasOne, 394 | key: 'listProperties', 395 | relatedModel: Properties 396 | } 397 | ] 398 | }); 399 | 400 | view = new View(); 401 | ok( _.size( view._relations ) === 1 ); 402 | }); 403 | 404 | QUnit.test( "'key' can be a string or an object reference", function() { 405 | var Properties = Backbone.Relational.Model.extend({}); 406 | var View = Backbone.Relational.Model.extend({ 407 | relations: [ 408 | { 409 | type: Backbone.Relational.HasOne, 410 | key: 'listProperties', 411 | relatedModel: Properties 412 | } 413 | ] 414 | }); 415 | 416 | var view = new View(); 417 | ok( _.size( view._relations ) === 1 ); 418 | 419 | View = Backbone.Relational.Model.extend({ 420 | relations: [ 421 | { 422 | type: Backbone.Relational.HasOne, 423 | key: 'listProperties', 424 | relatedModel: Properties 425 | } 426 | ] 427 | }); 428 | 429 | view = new View(); 430 | ok( _.size( view._relations ) === 1 ); 431 | }); 432 | 433 | QUnit.test( "HasMany with a reverseRelation HasMany is not allowed", function() { 434 | var User = Backbone.Relational.Model.extend({}); 435 | var Password = Backbone.Relational.Model.extend({ 436 | relations: [{ 437 | type: 'HasMany', 438 | key: 'users', 439 | relatedModel: User, 440 | reverseRelation: { 441 | type: 'HasMany', 442 | key: 'passwords' 443 | } 444 | }] 445 | }); 446 | 447 | var password = new Password({ 448 | plaintext: 'qwerty', 449 | users: [ 'person-1', 'person-2', 'person-3' ] 450 | }); 451 | 452 | ok( _.size( password._relations ) === 0, "No _relations created on Password" ); 453 | }); 454 | 455 | QUnit.test( "Duplicate relations not allowed (two simple relations)", function() { 456 | var Properties = Backbone.Relational.Model.extend({}); 457 | var View = Backbone.Relational.Model.extend({ 458 | relations: [ 459 | { 460 | type: Backbone.Relational.HasOne, 461 | key: 'properties', 462 | relatedModel: Properties 463 | }, 464 | { 465 | type: Backbone.Relational.HasOne, 466 | key: 'properties', 467 | relatedModel: Properties 468 | } 469 | ] 470 | }); 471 | 472 | var view = new View(); 473 | view.set( { properties: new Properties() } ); 474 | ok( _.size( view._relations ) === 1 ); 475 | }); 476 | 477 | QUnit.test( "Duplicate relations not allowed (one relation with a reverse relation, one without)", function() { 478 | var Properties = Backbone.Relational.Model.extend({}); 479 | var View = Backbone.Relational.Model.extend({ 480 | relations: [ 481 | { 482 | type: Backbone.Relational.HasOne, 483 | key: 'properties', 484 | relatedModel: Properties, 485 | reverseRelation: { 486 | type: Backbone.Relational.HasOne, 487 | key: 'view' 488 | } 489 | }, 490 | { 491 | type: Backbone.Relational.HasOne, 492 | key: 'properties', 493 | relatedModel: Properties 494 | } 495 | ] 496 | }); 497 | 498 | var view = new View(); 499 | view.set( { properties: new Properties() } ); 500 | ok( _.size( view._relations ) === 1 ); 501 | }); 502 | 503 | QUnit.test( "Duplicate relations not allowed (two relations with reverse relations)", function() { 504 | var Properties = Backbone.Relational.Model.extend({}); 505 | var View = Backbone.Relational.Model.extend({ 506 | relations: [ 507 | { 508 | type: Backbone.Relational.HasOne, 509 | key: 'properties', 510 | relatedModel: Properties, 511 | reverseRelation: { 512 | type: Backbone.Relational.HasOne, 513 | key: 'view' 514 | } 515 | }, 516 | { 517 | type: Backbone.Relational.HasOne, 518 | key: 'properties', 519 | relatedModel: Properties, 520 | reverseRelation: { 521 | type: Backbone.Relational.HasOne, 522 | key: 'view' 523 | } 524 | } 525 | ] 526 | }); 527 | 528 | var view = new View(); 529 | view.set( { properties: new Properties() } ); 530 | ok( _.size( view._relations ) === 1 ); 531 | }); 532 | 533 | QUnit.test( "Duplicate relations not allowed (different relations, reverse relations)", function() { 534 | var Properties = Backbone.Relational.Model.extend({}); 535 | var View = Backbone.Relational.Model.extend({ 536 | relations: [ 537 | { 538 | type: Backbone.Relational.HasOne, 539 | key: 'listProperties', 540 | relatedModel: Properties, 541 | reverseRelation: { 542 | type: Backbone.Relational.HasOne, 543 | key: 'view' 544 | } 545 | }, 546 | { 547 | type: Backbone.Relational.HasOne, 548 | key: 'windowProperties', 549 | relatedModel: Properties, 550 | reverseRelation: { 551 | type: Backbone.Relational.HasOne, 552 | key: 'view' 553 | } 554 | } 555 | ] 556 | }); 557 | 558 | var view = new View(), 559 | prop1 = new Properties( { name: 'a' } ), 560 | prop2 = new Properties( { name: 'b' } ); 561 | 562 | view.set( { listProperties: prop1, windowProperties: prop2 } ); 563 | 564 | ok( _.size( view._relations ) === 2 ); 565 | ok( _.size( prop1._relations ) === 1 ); 566 | ok( view.get( 'listProperties' ).get( 'name' ) === 'a' ); 567 | ok( view.get( 'windowProperties' ).get( 'name' ) === 'b' ); 568 | }); 569 | 570 | QUnit.module( "Backbone.Relational.Relation general", { setup: require('./setup/setup').reset } ); 571 | 572 | 573 | QUnit.test( "Only valid models (no validation failure) should be added to a relation", function() { 574 | var zoo = new Zoo(); 575 | 576 | zoo.on( 'add:animals', function( animal ) { 577 | ok( animal instanceof Animal ); 578 | }); 579 | 580 | var smallElephant = new Animal( { name: 'Jumbo', species: 'elephant', weight: 2000, livesIn: zoo } ); 581 | equal( zoo.get( 'animals' ).length, 1, "Just 1 elephant in the zoo" ); 582 | 583 | // should fail validation, so it shouldn't be added 584 | zoo.get( 'animals' ).add( { name: 'Big guy', species: 'elephant', weight: 13000 }, { validate: true } ); 585 | 586 | equal( zoo.get( 'animals' ).length, 1, "Still just 1 elephant in the zoo" ); 587 | }); 588 | 589 | QUnit.test( "Updating (retrieving) a model keeps relation consistency intact", function() { 590 | var zoo = new Zoo(); 591 | 592 | var lion = new Animal({ 593 | species: 'Lion', 594 | livesIn: zoo 595 | }); 596 | 597 | equal( zoo.get( 'animals' ).length, 1 ); 598 | 599 | lion.set({ 600 | id: 5, 601 | species: 'Lion', 602 | livesIn: zoo 603 | }); 604 | 605 | equal( zoo.get( 'animals' ).length, 1 ); 606 | 607 | zoo.set({ 608 | name: 'Dierenpark Amersfoort', 609 | animals: [ 5 ] 610 | }); 611 | 612 | equal( zoo.get( 'animals' ).length, 1 ); 613 | ok( zoo.get( 'animals' ).at( 0 ) === lion, "lion is in zoo" ); 614 | ok( lion.get( 'livesIn' ) === zoo ); 615 | 616 | var elephant = new Animal({ 617 | species: 'Elephant', 618 | livesIn: zoo 619 | }); 620 | 621 | equal( zoo.get( 'animals' ).length, 2 ); 622 | ok( elephant.get( 'livesIn' ) === zoo ); 623 | 624 | zoo.set({ 625 | id: 2 626 | }); 627 | 628 | equal( zoo.get( 'animals' ).length, 2 ); 629 | ok( lion.get( 'livesIn' ) === zoo ); 630 | ok( elephant.get( 'livesIn' ) === zoo ); 631 | }); 632 | 633 | QUnit.test( "Setting id on objects with reverse relations updates related collection correctly", function() { 634 | var zoo1 = new Zoo(); 635 | 636 | ok( zoo1.get( 'animals' ).size() === 0, "zoo has no animals" ); 637 | 638 | var lion = new Animal( { livesIn: 2 } ); 639 | zoo1.set( 'id', 2 ); 640 | 641 | ok( lion.get( 'livesIn' ) === zoo1, "zoo1 connected to lion" ); 642 | ok( zoo1.get( 'animals' ).length === 1, "zoo1 has one Animal" ); 643 | ok( zoo1.get( 'animals' ).at( 0 ) === lion, "lion added to zoo1" ); 644 | ok( zoo1.get( 'animals' ).get( lion ) === lion, "lion can be retrieved from zoo1" ); 645 | 646 | lion.set( { id: 5, livesIn: 2 } ); 647 | 648 | ok( lion.get( 'livesIn' ) === zoo1, "zoo1 connected to lion" ); 649 | ok( zoo1.get( 'animals' ).length === 1, "zoo1 has one Animal" ); 650 | ok( zoo1.get( 'animals' ).at( 0 ) === lion, "lion added to zoo1" ); 651 | ok( zoo1.get( 'animals' ).get( lion ) === lion, "lion can be retrieved from zoo1" ); 652 | 653 | // Other way around 654 | var elephant = new Animal( { id: 6 } ), 655 | tiger = new Animal( { id: 7 } ), 656 | zoo2 = new Zoo( { animals: [ 6 ] } ); 657 | 658 | ok( elephant.get( 'livesIn' ) === zoo2, "zoo2 connected to elephant" ); 659 | ok( zoo2.get( 'animals' ).length === 1, "zoo2 has one Animal" ); 660 | ok( zoo2.get( 'animals' ).at( 0 ) === elephant, "elephant added to zoo2" ); 661 | ok( zoo2.get( 'animals' ).get( elephant ) === elephant, "elephant can be retrieved from zoo2" ); 662 | 663 | zoo2.set( { id: 5, animals: [ 6, 7 ] } ); 664 | 665 | ok( elephant.get( 'livesIn' ) === zoo2, "zoo2 connected to elephant" ); 666 | ok( tiger.get( 'livesIn' ) === zoo2, "zoo2 connected to tiger" ); 667 | ok( zoo2.get( 'animals' ).length === 2, "zoo2 has one Animal" ); 668 | ok( zoo2.get( 'animals' ).at( 0 ) === elephant, "elephant added to zoo2" ); 669 | ok( zoo2.get( 'animals' ).at( 1 ) === tiger, "tiger added to zoo2" ); 670 | ok( zoo2.get( 'animals' ).get( elephant ) === elephant, "elephant can be retrieved from zoo2" ); 671 | ok( zoo2.get( 'animals' ).get( tiger ) === tiger, "tiger can be retrieved from zoo2" ); 672 | }); 673 | 674 | QUnit.test( "Collections can be passed as attributes on creation", function() { 675 | var animals = new AnimalCollection([ 676 | { id: 1, species: 'Lion' }, 677 | { id: 2 ,species: 'Zebra' } 678 | ]); 679 | 680 | var zoo = new Zoo( { animals: animals } ); 681 | 682 | equal( zoo.get( 'animals' ), animals, "The 'animals' collection has been set as the zoo's animals" ); 683 | equal( zoo.get( 'animals' ).length, 2, "Two animals in 'zoo'" ); 684 | 685 | zoo.destroy(); 686 | 687 | var newZoo = new Zoo( { animals: animals.models } ); 688 | 689 | ok( newZoo.get( 'animals' ).length === 2, "Two animals in the 'newZoo'" ); 690 | }); 691 | 692 | QUnit.test( "Models can be passed as attributes on creation", function() { 693 | var artis = new Zoo( { name: 'Artis' } ); 694 | 695 | var animal = new Animal( { species: 'Hippo', livesIn: artis }); 696 | 697 | equal( artis.get( 'animals' ).at( 0 ), animal, "Artis has a Hippo" ); 698 | equal( animal.get( 'livesIn' ), artis, "The Hippo is in Artis" ); 699 | }); 700 | 701 | QUnit.test( "id checking handles `undefined`, `null`, `0` ids properly", function() { 702 | var parent = new Node(); 703 | var child = new Node( { parent: parent } ); 704 | 705 | ok( child.get( 'parent' ) === parent ); 706 | parent.destroy(); 707 | ok( child.get( 'parent' ) === null, child.get( 'parent' ) + ' === null' ); 708 | 709 | // It used to be the case that `randomOtherNode` became `child`s parent here, since both the `parent.id` 710 | // (which is stored as the relation's `keyContents`) and `randomOtherNode.id` were undefined. 711 | var randomOtherNode = new Node(); 712 | ok( child.get( 'parent' ) === null, child.get( 'parent' ) + ' === null' ); 713 | 714 | // Create a child with parent id=0, then create the parent 715 | child = new Node( { parent: 0 } ); 716 | ok( child.get( 'parent' ) === null, child.get( 'parent' ) + ' === null' ); 717 | 718 | parent = new Node( { id: 0 } ); 719 | ok( child.get( 'parent' ) === parent ); 720 | 721 | child.destroy(); 722 | parent.destroy(); 723 | 724 | // The other way around; create the parent with id=0, then the child 725 | parent = new Node( { id: 0 } ); 726 | equal( parent.get( 'children' ).length, 0 ); 727 | 728 | child = new Node( { parent: 0 } ); 729 | ok( child.get( 'parent' ) === parent ); 730 | }); 731 | 732 | QUnit.test( "Relations are not affected by `silent: true`", function() { 733 | var ceo = new Person( { id: 1 } ); 734 | var company = new Company( { 735 | employees: [ { id: 2 }, { id: 3 }, 4 ], 736 | ceo: 1 737 | }, { silent: true } ), 738 | employees = company.get( 'employees' ), 739 | employee = employees.first(); 740 | 741 | ok( company.get( 'ceo' ) === ceo ); 742 | ok( employees instanceof Backbone.Relational.Collection ); 743 | equal( employees.length, 2 ); 744 | 745 | employee.set( 'company', null, { silent: true } ); 746 | equal( employees.length, 1 ); 747 | 748 | employees.add( employee, { silent: true } ); 749 | ok( employee.get( 'company' ) === company ); 750 | 751 | ceo.set( 'runs', null, { silent: true } ); 752 | ok( !company.get( 'ceo' ) ); 753 | 754 | var employee4 = new Job( { id: 4 } ); 755 | equal( employees.length, 3 ); 756 | }); 757 | 758 | QUnit.test( "Repeated model initialization and a collection should not break existing models", function () { 759 | var dataCompanyA = { 760 | id: 'company-a', 761 | name: 'Big Corp.', 762 | employees: [ { id: 'job-a' }, { id: 'job-b' } ] 763 | }; 764 | var dataCompanyB = { 765 | id: 'company-b', 766 | name: 'Small Corp.', 767 | employees: [] 768 | }; 769 | 770 | var companyA = new Company( dataCompanyA ); 771 | 772 | // Attempting to instantiate another model with the same data will throw an error 773 | throws( function() { new Company( dataCompanyA ); }, "Can only instantiate one model for a given `id` (per model type)" ); 774 | 775 | // init-ed a lead and its nested contacts are a collection 776 | ok( companyA.get('employees') instanceof Backbone.Relational.Collection, "Company's employees should be a collection" ); 777 | equal(companyA.get('employees').length, 2, 'with elements'); 778 | 779 | var CompanyCollection = Backbone.Relational.Collection.extend({ 780 | model: Company 781 | }); 782 | var companyCollection = new CompanyCollection( [ dataCompanyA, dataCompanyB ] ); 783 | 784 | // After loading a collection with models of the same type 785 | // the existing company should still have correct collections 786 | ok( companyCollection.get( dataCompanyA.id ) === companyA ); 787 | ok( companyA.get('employees') instanceof Backbone.Relational.Collection, "Company's employees should still be a collection" ); 788 | equal( companyA.get('employees').length, 2, 'with elements' ); 789 | }); 790 | 791 | QUnit.test( "Destroy removes models from (non-reverse) relations", function() { 792 | var agent = new Agent( { id: 1, customers: [ 2, 3, 4 ], address: { city: 'Utrecht' } } ); 793 | 794 | var c2 = new Customer( { id: 2 } ); 795 | var c3 = new Customer( { id: 3 } ); 796 | var c4 = new Customer( { id: 4 } ); 797 | 798 | ok( agent.get( 'customers' ).length === 3 ); 799 | 800 | c2.destroy(); 801 | 802 | ok( agent.get( 'customers' ).length === 2 ); 803 | ok( agent.get( 'customers' ).get( c3 ) === c3 ); 804 | ok( agent.get( 'customers' ).get( c4 ) === c4 ); 805 | 806 | agent.get( 'customers' ).remove( c3 ); 807 | 808 | ok( agent.get( 'customers' ).length === 1 ); 809 | 810 | ok( agent.get( 'address' ) instanceof Address ); 811 | 812 | agent.get( 'address' ).destroy(); 813 | 814 | ok( !agent.get( 'address' ) ); 815 | 816 | agent.destroy(); 817 | 818 | equal( agent.get( 'customers' ).length, 0 ); 819 | }); 820 | 821 | QUnit.test( "If keySource is used, don't remove a model that is present in the key attribute", function() { 822 | var ForumPost = Backbone.Relational.Model.extend({ 823 | // Normally would set something here, not needed for test 824 | }); 825 | var Forum = Backbone.Relational.Model.extend({ 826 | relations: [{ 827 | type: Backbone.Relational.HasMany, 828 | key: 'posts', 829 | relatedModel: ForumPost, 830 | reverseRelation: { 831 | key: 'forum', 832 | keySource: 'forum_id' 833 | } 834 | }] 835 | }); 836 | 837 | var testPost = new ForumPost({ 838 | id: 1, 839 | title: 'Hello World', 840 | forum: { id: 1, title: 'Cupcakes' } 841 | }); 842 | 843 | var testForum = Forum.findOrCreate( 1 ); 844 | 845 | notEqual( testPost.get( 'forum' ), null, "The post's forum is not null" ); 846 | equal( testPost.get( 'forum' ).get( 'title' ), "Cupcakes", "The post's forum title is Cupcakes" ); 847 | equal( testForum.get( 'title' ), "Cupcakes", "A forum of id 1 has the title cupcakes" ); 848 | 849 | var testPost2 = new ForumPost({ 850 | id: 3, 851 | title: 'Hello World', 852 | forum: { id: 2, title: 'Donuts' }, 853 | forum_id: 3 854 | }); 855 | 856 | notEqual( testPost2.get( 'forum' ), null, "The post's forum is not null" ); 857 | equal( testPost2.get( 'forum' ).get( 'title' ), "Donuts", "The post's forum title is Donuts" ); 858 | deepEqual( testPost2.getRelation( 'forum' ).keyContents, { id: 2, title: 'Donuts' }, 'The expected forum is 2' ); 859 | equal( testPost2.getRelation( 'forum' ).keyId, null, "There's no expected forum anymore" ); 860 | 861 | var testPost3 = new ForumPost({ 862 | id: 4, 863 | title: 'Hello World', 864 | forum: null, 865 | forum_id: 3 866 | }); 867 | 868 | equal( testPost3.get( 'forum' ), null, "The post's forum is null" ); 869 | equal( testPost3.getRelation( 'forum' ).keyId, 3, 'Forum is expected to have id=3' ); 870 | }); 871 | 872 | // GH-187 873 | QUnit.test( "Can pass related model in constructor", function() { 874 | var A = Backbone.Relational.Model.extend(); 875 | var B = Backbone.Relational.Model.extend({ 876 | relations: [{ 877 | type: Backbone.Relational.HasOne, 878 | key: 'a', 879 | keySource: 'a_id', 880 | relatedModel: A 881 | }] 882 | }); 883 | 884 | var a1 = new A({ id: 'a1' }); 885 | var b1 = new B(); 886 | b1.set( 'a', a1 ); 887 | ok( b1.get( 'a' ) instanceof A ); 888 | ok( b1.get( 'a' ).id === 'a1' ); 889 | 890 | var a2 = new A({ id: 'a2' }); 891 | var b2 = new B({ a: a2 }); 892 | ok( b2.get( 'a' ) instanceof A ); 893 | ok( b2.get( 'a' ).id === 'a2' ); 894 | }); 895 | -------------------------------------------------------------------------------- /test/relational-model.js: -------------------------------------------------------------------------------- 1 | QUnit.module( "Backbone.Relational.Model", { setup: require('./setup/data') } ); 2 | 3 | QUnit.test( "Return values: set returns the Model", function() { 4 | var personId = 'person-10'; 5 | var person = new Person({ 6 | id: personId, 7 | name: 'Remi', 8 | resource_uri: personId 9 | }); 10 | 11 | var result = person.set( { 'name': 'Hector' } ); 12 | ok( result === person, "Set returns the model" ); 13 | }); 14 | 15 | QUnit.test( "`clear`", function() { 16 | var person = new Person( { id: 'person-10' } ); 17 | 18 | ok( person === Person.findOrCreate( 'person-10' ) ); 19 | 20 | person.clear(); 21 | 22 | ok( !person.id ); 23 | 24 | ok( !Person.findOrCreate( 'person-10' ) ); 25 | 26 | person.set( { id: 'person-10' } ); 27 | 28 | ok( person === Person.findOrCreate( 'person-10' ) ); 29 | }); 30 | 31 | QUnit.test( "getRelations", function() { 32 | var relations = person1.getRelations(); 33 | 34 | equal( relations.length, 6 ); 35 | 36 | ok( _.every( relations, function( rel ) { 37 | return rel instanceof Backbone.Relational.Relation; 38 | }) 39 | ); 40 | }); 41 | 42 | QUnit.test( "getRelation", function() { 43 | var userRel = person1.getRelation( 'user' ); 44 | 45 | ok( userRel instanceof Backbone.Relational.HasOne ); 46 | equal( userRel.key, 'user' ); 47 | 48 | var jobsRel = person1.getRelation( 'jobs' ); 49 | 50 | ok( jobsRel instanceof Backbone.Relational.HasMany ); 51 | equal( jobsRel.key, 'jobs' ); 52 | 53 | ok( person1.getRelation( 'nope' ) == null ); 54 | }); 55 | 56 | QUnit.test( "getAsync on a HasOne relation", function() { 57 | var errorCount = 0; 58 | var person = new Person({ 59 | id: 'person-10', 60 | resource_uri: 'person-10', 61 | user: 'user-10' 62 | }); 63 | 64 | var idsToFetch = person.getIdsToFetch( 'user' ); 65 | deepEqual( idsToFetch, [ 'user-10' ] ); 66 | 67 | var request = person.getAsync( 'user', { error: function() { 68 | errorCount++; 69 | } 70 | }); 71 | 72 | ok( _.isObject( request ) && request.always && request.done && request.fail ); 73 | equal( window.requests.length, 1, "A single request has been made" ); 74 | ok( person.get( 'user' ) instanceof User ); 75 | 76 | // Triggering the 'error' callback should destroy the model 77 | window.requests[ 0 ].error(); 78 | // Trigger the 'success' callback on the `destroy` call to actually fire the 'destroy' event 79 | _.last( window.requests ).success(); 80 | 81 | ok( !person.get( 'user' ), "User has been destroyed & removed" ); 82 | equal( errorCount, 1, "The error callback executed successfully" ); 83 | 84 | var person2 = new Person({ 85 | id: 'person-11', 86 | resource_uri: 'person-11' 87 | }); 88 | 89 | request = person2.getAsync( 'user' ); 90 | equal( window.requests.length, 1, "No request was made" ); 91 | }); 92 | 93 | QUnit.test( "getAsync on a HasMany relation", function() { 94 | var errorCount = 0; 95 | var zoo = new Zoo({ 96 | animals: [ { id: 'monkey-1' }, 'lion-1', 'zebra-1' ] 97 | }); 98 | 99 | var idsToFetch = zoo.getIdsToFetch( 'animals' ); 100 | deepEqual( idsToFetch, [ 'lion-1', 'zebra-1' ] ); 101 | 102 | /** 103 | * Case 1: separate requests for each model 104 | */ 105 | window.requests = []; 106 | 107 | // `getAsync` creates two placeholder models for the ids present in the relation. 108 | var request = zoo.getAsync( 'animals', { error: function() { errorCount++; } } ); 109 | 110 | ok( _.isObject( request ) && request.always && request.done && request.fail ); 111 | equal( window.requests.length, 2, "Two requests have been made (a separate one for each animal)" ); 112 | equal( zoo.get( 'animals' ).length, 3, "Three animals in the zoo" ); 113 | 114 | // Triggering the 'error' callback for one request should destroy the model 115 | window.requests[ 0 ].error(); 116 | // Trigger the 'success' callback on the `destroy` call to actually fire the 'destroy' event 117 | _.last( window.requests ).success(); 118 | 119 | equal( zoo.get( 'animals' ).length, 2, "Two animals left in the zoo" ); 120 | equal( errorCount, 1, "The error callback executed successfully" ); 121 | 122 | // Try to re-fetch; nothing left to get though, since the placeholder models got destroyed 123 | window.requests = []; 124 | request = zoo.getAsync( 'animals' ); 125 | 126 | equal( window.requests.length, 0, "No request" ); 127 | equal( zoo.get( 'animals' ).length, 2, "Two animals" ); 128 | 129 | /** 130 | * Case 2: one request per fetch (generated by the collection) 131 | */ 132 | window.requests = []; 133 | errorCount = 0; 134 | 135 | // Define a `url` function for the zoo that builds a url to fetch a set of models from their ids 136 | zoo.get( 'animals' ).url = function( models ) { 137 | var ids = _.map( models || [], function( model ) { 138 | return model instanceof Backbone.Model ? model.id : model; 139 | } ); 140 | 141 | return '/animal/' + ( ids.length ? 'set/' + ids.join( ';' ) + '/' : '' ); 142 | }; 143 | 144 | // Set two new animals to be fetched; both should be fetched in a single request. 145 | zoo.set( { animals: [ 'monkey-1', 'lion-2', 'zebra-2' ] } ); 146 | 147 | equal( zoo.get( 'animals' ).length, 1, "One animal" ); 148 | 149 | // `getAsync` should not create placeholder models in this case, since the custom `url` function 150 | // can return a url for the whole set without needing to resort to this. 151 | window.requests = []; 152 | request = zoo.getAsync( 'animals', { error: function() { errorCount++; } } ); 153 | 154 | ok( _.isObject( request ) && request.always && request.done && request.fail ); 155 | equal( window.requests.length, 1, "One request" ); 156 | equal( _.last( window.requests ).url, '/animal/set/lion-2;zebra-2/' ); 157 | equal( zoo.get('animals').length, 1, "Still only one animal in the zoo" ); 158 | 159 | // Triggering the 'error' callback (some error occured during fetching) should trigger the 'destroy' event 160 | // on both fetched models, but should NOT actually make 'delete' requests to the server! 161 | _.last( window.requests ).error(); 162 | equal( window.requests.length, 1, "An error occured when fetching, but no DELETE requests are made to the server while handling local cleanup." ); 163 | 164 | equal( zoo.get( 'animals' ).length, 1, "Both animals are destroyed" ); 165 | equal( errorCount, 1, "The error callback executed successfully" ); 166 | 167 | // Try to re-fetch; attempts to get both missing animals again 168 | window.requests = []; 169 | request = zoo.getAsync( 'animals' ); 170 | 171 | equal( window.requests.length, 1, "One request" ); 172 | equal( zoo.get( 'animals' ).length, 1, "One animal" ); 173 | 174 | // In this case, models are only created after receiving data for them 175 | window.requests[ 0 ].success( [ { id: 'lion-2' }, { id: 'zebra-2' } ] ); 176 | equal( zoo.get( 'animals' ).length, 3 ); 177 | 178 | // Re-fetch the existing models 179 | window.requests = []; 180 | request = zoo.getAsync( 'animals', { refresh: true } ); 181 | 182 | equal( window.requests.length, 1 ); 183 | equal( _.last( window.requests ).url, '/animal/set/monkey-1;lion-2;zebra-2/' ); 184 | equal( zoo.get( 'animals' ).length, 3 ); 185 | 186 | // An error while refreshing existing models shouldn't affect it 187 | window.requests[ 0 ].error(); 188 | equal( zoo.get( 'animals' ).length, 3 ); 189 | }); 190 | 191 | QUnit.test( "getAsync", 8, function() { 192 | var zoo = Zoo.findOrCreate( { id: 'z-1', animals: [ 'cat-1' ] } ); 193 | 194 | zoo.on( 'add:animals', function( animal ) { 195 | console.log( 'add:animals=%o', animal ); 196 | animal.on( 'change:favoriteFood', function( model, food ) { 197 | console.log( '%s eats %s', animal.get( 'name' ), food.get( 'name' ) ); 198 | }); 199 | }); 200 | 201 | zoo.getAsync( 'animals' ).done( function( animals ) { 202 | ok( animals instanceof AnimalCollection ); 203 | ok( animals.length === 1 ); 204 | 205 | var cat = zoo.get( 'animals' ).at( 0 ); 206 | equal( cat.get( 'name' ), 'Tiger' ); 207 | 208 | cat.getAsync( 'favoriteFood' ).done( function( food ) { 209 | equal( food.get( 'name' ), 'Cheese', 'Favorite food is cheese' ); 210 | }); 211 | }); 212 | 213 | equal( zoo.get( 'animals' ).length, 1 ); 214 | equal( window.requests.length, 1 ); 215 | equal( _.last( window.requests ).url, '/animal/cat-1' ); 216 | 217 | // Declare success 218 | _.last( window.requests ).respond( 200, { id: 'cat-1', name: 'Tiger', favoriteFood: 'f-2' } ); 219 | equal( window.requests.length, 2 ); 220 | 221 | _.last( window.requests ).respond( 200, { id: 'f-2', name: 'Cheese' } ); 222 | }); 223 | 224 | QUnit.test( "autoFetch a HasMany relation", function() { 225 | var shopOne = new Shop({ 226 | id: 'shop-1', 227 | customers: ['customer-1', 'customer-2'] 228 | }); 229 | 230 | equal( requests.length, 2, "Two requests to fetch the users has been made" ); 231 | requests.length = 0; 232 | 233 | var shopTwo = new Shop({ 234 | id: 'shop-2', 235 | customers: ['customer-1', 'customer-3'] 236 | }); 237 | 238 | equal( requests.length, 1, "A request to fetch a user has been made" ); //as customer-1 has already been fetched 239 | }); 240 | 241 | QUnit.test( "autoFetch on a HasOne relation (with callbacks)", function() { 242 | var shopThree = new Shop({ 243 | id: 'shop-3', 244 | address: 'address-3' 245 | }); 246 | 247 | equal( requests.length, 1, "A request to fetch the address has been made" ); 248 | 249 | var res = { successOK: false, errorOK: false }; 250 | 251 | requests[0].success( res ); 252 | equal( res.successOK, true, "The success() callback has been called" ); 253 | requests.length = 0; 254 | 255 | var shopFour = new Shop({ 256 | id: 'shop-4', 257 | address: 'address-4' 258 | }); 259 | 260 | equal( requests.length, 1, "A request to fetch the address has been made" ); 261 | requests[0].error( res ); 262 | equal( res.errorOK, true, "The error() callback has been called" ); 263 | }); 264 | 265 | QUnit.test( "autoFetch false by default", function() { 266 | var agentOne = new Agent({ 267 | id: 'agent-1', 268 | customers: ['customer-4', 'customer-5'] 269 | }); 270 | 271 | equal( requests.length, 0, "No requests to fetch the customers has been made as autoFetch was not defined" ); 272 | 273 | agentOne = new Agent({ 274 | id: 'agent-2', 275 | address: 'address-5' 276 | }); 277 | 278 | equal( requests.length, 0, "No requests to fetch the customers has been made as autoFetch was set to false" ); 279 | }); 280 | 281 | QUnit.test( "`clone`", function() { 282 | var user = person1.get( 'user' ); 283 | 284 | // HasOne relations should stay with the original model 285 | var newPerson = person1.clone(); 286 | 287 | ok( newPerson.get( 'user' ) === null ); 288 | ok( person1.get( 'user' ) === user ); 289 | }); 290 | 291 | QUnit.test( "`save` (with `wait`)", function() { 292 | var node1 = new Node({ id: '1', parent: '3', name: 'First node' } ), 293 | node2 = new Node({ id: '2', name: 'Second node' }); 294 | 295 | // Set node2's parent to node1 in a request with `wait: true` 296 | var request = node2.save( 'parent', node1, { wait: true } ), 297 | json = JSON.parse( request.data ); 298 | 299 | ok( _.isObject( json.parent ) ); 300 | equal( json.parent.id, '1' ); 301 | equal( node2.get( 'parent' ), null ); 302 | 303 | request.success(); 304 | 305 | equal( node2.get( 'parent' ), node1 ); 306 | 307 | // Save a new node as node2's parent, only specified as JSON in the call to save 308 | request = node2.save( 'parent', { id: '3', parent: '2', name: 'Third node' }, { wait: true } ); 309 | json = JSON.parse( request.data ); 310 | 311 | ok( _.isObject( json.parent ) ); 312 | equal( json.parent.id, '3' ); 313 | equal( node2.get( 'parent' ), node1 ); 314 | 315 | request.success(); 316 | 317 | var node3 = node2.get( 'parent' ); 318 | 319 | ok( node3 instanceof Node ); 320 | equal( node3.id, '3' ); 321 | 322 | // Try to reset node2's parent to node1, but fail the request 323 | request = node2.save( 'parent', node1, { wait: true } ); 324 | request.error(); 325 | 326 | equal( node2.get( 'parent' ), node3 ); 327 | 328 | // See what happens for different values of `includeInJSON`... 329 | // For `Person.user`, just the `idAttribute` should be serialized to the keyDestination `user_id` 330 | var user1 = person1.get( 'user' ); 331 | request = person1.save( 'user', null, { wait: true } ); 332 | json = JSON.parse( request.data ); 333 | console.log( request, json ); 334 | 335 | equal( person1.get( 'user' ), user1 ); 336 | 337 | request.success( json ); 338 | 339 | equal( person1.get( 'user' ), null ); 340 | 341 | request = person1.save( 'user', user1, { wait: true } ); 342 | json = JSON.parse( request.data ); 343 | 344 | equal( json.user_id, user1.id ); 345 | equal( person1.get( 'user' ), null ); 346 | 347 | request.success( json ); 348 | 349 | equal( person1.get( 'user' ), user1 ); 350 | 351 | // Save a collection with `wait: true` 352 | var zoo = new Zoo( { id: 'z1' } ), 353 | animal1 = new Animal( { id: 'a1', species: 'Goat', name: 'G' } ), 354 | coll = new Backbone.Relational.Collection( [ { id: 'a2', species: 'Rabbit', name: 'R' }, animal1 ] ); 355 | 356 | request = zoo.save( 'animals', coll, { wait: true } ); 357 | json = JSON.parse( request.data ); 358 | console.log( request, json ); 359 | 360 | ok( zoo.get( 'animals' ).length === 0 ); 361 | 362 | request.success( json ); 363 | 364 | ok( zoo.get( 'animals' ).length === 2 ); 365 | console.log( animal1 ); 366 | }); 367 | 368 | QUnit.test( "`Collection.create` (with `wait`)", function() { 369 | var nodeColl = new NodeList(), 370 | nodesAdded = 0; 371 | 372 | nodeColl.on( 'add', function( model, collection, options ) { 373 | nodesAdded++; 374 | }); 375 | 376 | nodeColl.create({ id: '3', parent: '2', name: 'Third node' }, { wait: true }); 377 | ok( nodesAdded === 0 ); 378 | requests[ requests.length - 1 ].success(); 379 | ok( nodesAdded === 1 ); 380 | 381 | nodeColl.create({ id: '4', name: 'Third node' }, { wait: true }); 382 | ok( nodesAdded === 1 ); 383 | requests[ requests.length - 1 ].error(); 384 | ok( nodesAdded === 1 ); 385 | }); 386 | 387 | QUnit.test( "`toJSON`: simple cases", function() { 388 | var node = new Node({ id: '1', parent: '3', name: 'First node' }); 389 | new Node({ id: '2', parent: '1', name: 'Second node' }); 390 | new Node({ id: '3', parent: '2', name: 'Third node' }); 391 | 392 | var json = node.toJSON(); 393 | 394 | ok( json.children.length === 1 ); 395 | }); 396 | 397 | QUnit.test("'toJSON' should return null for relations that are set to null, even when model is not fetched", function() { 398 | var person = new Person( { user : 'u1' } ); 399 | 400 | equal( person.toJSON().user_id, 'u1' ); 401 | person.set( 'user', null ); 402 | equal( person.toJSON().user_id, null ); 403 | 404 | person = new Person( { user: new User( { id : 'u2' } ) } ); 405 | 406 | equal( person.toJSON().user_id, 'u2' ); 407 | person.set( { user: 'unfetched_user_id' } ); 408 | equal( person.toJSON().user_id, 'unfetched_user_id' ); 409 | }); 410 | 411 | QUnit.test( "`toJSON` should include ids for 'unknown' or 'missing' models (if `includeInJSON` is `idAttribute`)", function() { 412 | // See GH-191 413 | 414 | // `Zoo` shouldn't be affected; `animals.includeInJSON` is not equal to `idAttribute` 415 | var zoo = new Zoo({ id: 'z1', animals: [ 'a1', 'a2' ] }), 416 | zooJSON = zoo.toJSON(); 417 | 418 | ok( _.isArray( zooJSON.animals ) ); 419 | equal( zooJSON.animals.length, 0, "0 animals in zooJSON; it serializes an array of attributes" ); 420 | 421 | var a1 = new Animal( { id: 'a1' } ); 422 | zooJSON = zoo.toJSON(); 423 | equal( zooJSON.animals.length, 1, "1 animals in zooJSON; it serializes an array of attributes" ); 424 | 425 | // Agent -> Customer; `idAttribute` on a HasMany 426 | var agent = new Agent({ id: 'a1', customers: [ 'c1', 'c2' ] } ), 427 | agentJSON = agent.toJSON(); 428 | 429 | ok( _.isArray( agentJSON.customers ) ); 430 | equal( agentJSON.customers.length, 2, "2 customers in agentJSON; it serializes the `idAttribute`" ); 431 | 432 | var c1 = new Customer( { id: 'c1' } ); 433 | equal( agent.get( 'customers' ).length, 1, '1 customer in agent' ); 434 | 435 | agentJSON = agent.toJSON(); 436 | equal( agentJSON.customers.length, 2, "2 customers in agentJSON; `idAttribute` for 1 missing, other existing" ); 437 | 438 | //c1.destroy(); 439 | 440 | //agentJSON = agent.toJSON(); 441 | //equal( agentJSON.customers.length, 1, "1 customer in agentJSON; `idAttribute` for 1 missing, other destroyed" ); 442 | 443 | agent.set( 'customers', [ 'c1', 'c3' ] ); 444 | var c3 = new Customer( { id: 'c3' } ); 445 | 446 | agentJSON = agent.toJSON(); 447 | equal( agentJSON.customers.length, 2, "2 customers in agentJSON; 'c1' already existed, 'c3' created" ); 448 | 449 | agent.get( 'customers' ).remove( c1 ); 450 | 451 | agentJSON = agent.toJSON(); 452 | equal( agentJSON.customers.length, 1, "1 customer in agentJSON; 'c1' removed, 'c3' still in there" ); 453 | 454 | // Person -> User; `idAttribute` on a HasOne 455 | var person = new Person({ id: 'p1', user: 'u1' } ), 456 | personJSON = person.toJSON(); 457 | 458 | equal( personJSON.user_id, 'u1', "`user_id` gets set in JSON" ); 459 | 460 | var u1 = new User( { id: 'u1' } ); 461 | personJSON = person.toJSON(); 462 | ok( u1.get( 'person' ) === person ); 463 | equal( personJSON.user_id, 'u1', "`user_id` gets set in JSON" ); 464 | 465 | person.set( 'user', 'u1' ); 466 | personJSON = person.toJSON(); 467 | equal( personJSON.user_id, 'u1', "`user_id` gets set in JSON" ); 468 | 469 | u1.destroy(); 470 | personJSON = person.toJSON(); 471 | ok( !u1.get( 'person' ) ); 472 | equal( personJSON.user_id, 'u1', "`user_id` still gets set in JSON" ); 473 | }); 474 | 475 | QUnit.test( "`toJSON` should include ids for unregistered models (if `includeInJSON` is `idAttribute`)", function() { 476 | 477 | // Person -> User; `idAttribute` on a HasOne 478 | var person = new Person({ id: 'p1', user: 'u1' } ), 479 | personJSON = person.toJSON(); 480 | 481 | equal( personJSON.user_id, 'u1', "`user_id` gets set in JSON even though no user obj exists" ); 482 | 483 | var u1 = new User( { id: 'u1' } ); 484 | personJSON = person.toJSON(); 485 | ok( u1.get( 'person' ) === person ); 486 | equal( personJSON.user_id, 'u1', "`user_id` gets set in JSON after matching user obj is created" ); 487 | 488 | Backbone.Relational.store.unregister(u1); 489 | 490 | personJSON = person.toJSON(); 491 | equal( personJSON.user_id, 'u1', "`user_id` gets set in JSON after user was unregistered from store" ); 492 | }); 493 | 494 | QUnit.test( "`parse` gets called through `findOrCreate`", function() { 495 | var parseCalled = 0; 496 | Zoo.prototype.parse = Animal.prototype.parse = function( resp, options ) { 497 | parseCalled++; 498 | return resp; 499 | }; 500 | 501 | var zoo = Zoo.findOrCreate({ 502 | id: '1', 503 | name: 'San Diego Zoo', 504 | animals: [ { id: 'a' } ] 505 | }, { parse: true } ); 506 | var animal = zoo.get( 'animals' ).first(); 507 | 508 | ok( animal.get( 'livesIn' ) ); 509 | ok( animal.get( 'livesIn' ) instanceof Zoo ); 510 | ok( animal.get( 'livesIn' ).get( 'animals' ).get( animal ) === animal ); 511 | 512 | // `parse` gets called by `findOrCreate` directly when trying to lookup `1`, 513 | // and the parsed attributes are passed to `build` (called from `findOrCreate`) with `{ parse: false }`, 514 | // rather than having `parse` called again by the Zoo constructor. 515 | ok( parseCalled === 1, 'parse called 1 time? ' + parseCalled ); 516 | 517 | parseCalled = 0; 518 | 519 | animal = new Animal({ id: 'b' }); 520 | animal.set({ 521 | id: 'b', 522 | livesIn: { 523 | id: '2', 524 | name: 'San Diego Zoo', 525 | animals: [ 'b' ] 526 | } 527 | }, { parse: true } ); 528 | 529 | ok( animal.get( 'livesIn' ) ); 530 | ok( animal.get( 'livesIn' ) instanceof Zoo ); 531 | ok( animal.get( 'livesIn' ).get( 'animals' ).get( animal ) === animal ); 532 | 533 | ok( parseCalled === 0, 'parse called 0 times? ' + parseCalled ); 534 | 535 | // Reset `parse` methods 536 | Zoo.prototype.parse = Animal.prototype.parse = Backbone.Relational.Model.prototype.parse; 537 | }); 538 | 539 | QUnit.test( "`Collection#parse` with RelationalModel simple case", function() { 540 | var Contact = Backbone.Relational.Model.extend({ 541 | parse: function( response ) { 542 | response.bar = response.foo * 2; 543 | return response; 544 | } 545 | }); 546 | var Contacts = Backbone.Relational.Collection.extend({ 547 | model: Contact, 548 | url: '/contacts', 549 | parse: function( response ) { 550 | return response.items; 551 | } 552 | }); 553 | 554 | var contacts = new Contacts(); 555 | contacts.fetch({ 556 | // fake response for testing 557 | response: { 558 | status: 200, 559 | responseText: { items: [ { foo: 1 }, { foo: 2 } ] } 560 | } 561 | }); 562 | 563 | equal( contacts.length, 2, 'Collection response was fetched properly' ); 564 | var contact = contacts.first(); 565 | ok( contact , 'Collection has a non-null item' ); 566 | ok( contact instanceof Contact, '... of the type type' ); 567 | equal( contact.get('foo'), 1, '... with correct fetched value' ); 568 | equal( contact.get('bar'), 2, '... with correct parsed value' ); 569 | }); 570 | 571 | QUnit.test( "By default, `parse` should only get called on top-level objects; not for nested models and collections", function() { 572 | var companyData = { 573 | 'data': { 574 | 'id': 'company-1', 575 | 'contacts': [ 576 | { 577 | 'id': '1' 578 | }, 579 | { 580 | 'id': '2' 581 | } 582 | ] 583 | } 584 | }; 585 | 586 | var Contact = Backbone.Relational.Model.extend(); 587 | var Contacts = Backbone.Relational.Collection.extend({ 588 | model: Contact 589 | }); 590 | 591 | var Company = Backbone.Relational.Model.extend({ 592 | urlRoot: '/company/', 593 | relations: [{ 594 | type: Backbone.Relational.HasMany, 595 | key: 'contacts', 596 | relatedModel: Contact, 597 | collectionType: Contacts 598 | }] 599 | }); 600 | 601 | var parseCalled = 0; 602 | Company.prototype.parse = Contact.prototype.parse = Contacts.prototype.parse = function( resp, options ) { 603 | parseCalled++; 604 | return resp.data || resp; 605 | }; 606 | 607 | var company = new Company( companyData, { parse: true } ), 608 | contacts = company.get( 'contacts' ), 609 | contact = contacts.first(); 610 | 611 | ok( company.id === 'company-1' ); 612 | ok( contact && contact.id === '1', 'contact exists' ); 613 | ok( parseCalled === 1, 'parse called 1 time? ' + parseCalled ); 614 | 615 | // simulate what would happen if company.fetch() was called. 616 | company.fetch({ 617 | parse: true, 618 | response: { 619 | status: 200, 620 | responseText: _.clone( companyData ) 621 | } 622 | }); 623 | 624 | ok( parseCalled === 2, 'parse called 2 times? ' + parseCalled ); 625 | 626 | ok( contacts === company.get( 'contacts' ), 'contacts collection is same instance after fetch' ); 627 | equal( contacts.length, 2, '... with correct length' ); 628 | ok( contact && contact.id === '1', 'contact exists' ); 629 | ok( contact === contacts.first(), '... and same model instances' ); 630 | }); 631 | 632 | QUnit.test( "constructor.findOrCreate", function() { 633 | var personColl = Backbone.Relational.store.getCollection( person1 ), 634 | origPersonCollSize = personColl.length; 635 | 636 | // Just find an existing model 637 | var person = Person.findOrCreate( person1.id ); 638 | 639 | ok( person === person1 ); 640 | ok( origPersonCollSize === personColl.length, "Existing person was found (none created)" ); 641 | 642 | // Update an existing model 643 | person = Person.findOrCreate( { id: person1.id, name: 'dude' } ); 644 | 645 | equal( person.get( 'name' ), 'dude' ); 646 | equal( person1.get( 'name' ), 'dude' ); 647 | 648 | ok( origPersonCollSize === personColl.length, "Existing person was updated (none created)" ); 649 | 650 | // Look for a non-existent person; 'options.create' is false 651 | person = Person.findOrCreate( { id: 5001 }, { create: false } ); 652 | 653 | ok( !person ); 654 | ok( origPersonCollSize === personColl.length, "No person was found (none created)" ); 655 | 656 | // Create a new model 657 | person = Person.findOrCreate( { id: 5001 } ); 658 | 659 | ok( person instanceof Person ); 660 | ok( origPersonCollSize + 1 === personColl.length, "No person was found (1 created)" ); 661 | 662 | // Find when options.merge is false 663 | person = Person.findOrCreate( { id: person1.id, name: 'phil' }, { merge: false } ); 664 | 665 | equal( person.get( 'name' ), 'dude' ); 666 | equal( person1.get( 'name' ), 'dude' ); 667 | }); 668 | 669 | QUnit.test( "constructor.find", function() { 670 | var personColl = Backbone.Relational.store.getCollection( person1 ), 671 | origPersonCollSize = personColl.length; 672 | 673 | // Look for a non-existent person 674 | person = Person.find( { id: 5001 } ); 675 | ok( !person ); 676 | }); 677 | 678 | QUnit.test( "change events in relation can use changedAttributes properly", function() { 679 | var scope = {}; 680 | Backbone.Relational.store.addModelScope( scope ); 681 | 682 | scope.PetAnimal = Backbone.Relational.Model.extend({ 683 | subModelTypes: { 684 | 'cat': 'Cat', 685 | 'dog': 'Dog' 686 | } 687 | }); 688 | scope.Dog = scope.PetAnimal.extend(); 689 | scope.Cat = scope.PetAnimal.extend(); 690 | 691 | scope.PetOwner = Backbone.Relational.Model.extend({ 692 | relations: [{ 693 | type: Backbone.Relational.HasMany, 694 | key: 'pets', 695 | relatedModel: scope.PetAnimal, 696 | reverseRelation: { 697 | key: 'owner' 698 | } 699 | }] 700 | }); 701 | 702 | var owner = new scope.PetOwner( { id: 'owner-2354' } ); 703 | var animal = new scope.Dog( { type: 'dog', id: '238902', color: 'blue' } ); 704 | equal( animal.get('color'), 'blue', 'animal starts out blue' ); 705 | 706 | var changes = 0, changedAttrs = null; 707 | animal.on('change', function(model, options) { 708 | changes++; 709 | changedAttrs = model.changedAttributes(); 710 | }); 711 | 712 | animal.set( { color: 'green' } ); 713 | equal( changes, 1, 'change event gets called after animal.set' ); 714 | equal( changedAttrs.color, 'green', '... with correct properties in "changedAttributes"' ); 715 | 716 | owner.set(owner.parse({ 717 | id: 'owner-2354', 718 | pets: [ { id: '238902', type: 'dog', color: 'red' } ] 719 | })); 720 | 721 | equal( animal.get('color'), 'red', 'color gets updated properly' ); 722 | equal( changes, 2, 'change event gets called after owner.set' ); 723 | equal( changedAttrs.color, 'red', '... with correct properties in "changedAttributes"' ); 724 | }); 725 | 726 | QUnit.test( 'change events should not fire on new items in Collection#set', function() { 727 | var modelChangeEvents = 0, 728 | collectionChangeEvents = 0; 729 | 730 | var Animal2 = Animal.extend({ 731 | initialize: function(options) { 732 | this.on( 'all', function( name, event ) { 733 | //console.log( 'Animal2: %o', arguments ); 734 | if ( name.indexOf( 'change' ) === 0 ) { 735 | modelChangeEvents++; 736 | } 737 | }); 738 | } 739 | }); 740 | 741 | var AnimalCollection2 = AnimalCollection.extend({ 742 | model: Animal2, 743 | 744 | initialize: function(options) { 745 | this.on( 'all', function( name, event ) { 746 | //console.log( 'AnimalCollection2: %o', arguments ); 747 | if ( name.indexOf('change') === 0 ) { 748 | collectionChangeEvents++; 749 | } 750 | }); 751 | } 752 | }); 753 | 754 | var zoo = new Zoo( { id: 'zoo-1' } ); 755 | 756 | var coll = new AnimalCollection2(); 757 | coll.set( [{ 758 | id: 'animal-1', 759 | livesIn: 'zoo-1' 760 | }] ); 761 | 762 | equal( collectionChangeEvents, 0, 'no change event should be triggered on the collection' ); 763 | 764 | modelChangeEvents = collectionChangeEvents = 0; 765 | 766 | coll.at( 0 ).set( 'name', 'Willie' ); 767 | 768 | equal( modelChangeEvents, 2, 'change event should be triggered' ); 769 | }); 770 | 771 | QUnit.test( "Model's collection children should be in the proper order during fetch w/remove: false", function() { 772 | var Child = Backbone.Relational.Model.extend(); 773 | var Parent = Backbone.Relational.Model.extend( { 774 | relations: [ { 775 | type: Backbone.Relational.HasMany, 776 | key: 'children', 777 | relatedModel: Child 778 | } ] 779 | } ); 780 | 781 | // initialize a child... there's no good reason why this should affect the test passing 782 | Child.findOrCreate( { id: 'foo1' } ); 783 | 784 | // simulate a fetch of the parent with nested children 785 | var parent = Parent.findOrCreate( { id: 'the-parent' } ); 786 | var children = parent.get( 'children' ); 787 | equal( children.length, 0 ); 788 | parent.set({ 789 | id: 'the-parent', 790 | children: [ 791 | { id: 'foo1' }, 792 | { id: 'foo2' } 793 | ] 794 | }, { 795 | remove: false // maybe necessary in case you have other relations with isNew models, etc. 796 | }); 797 | 798 | // check order of parent's children 799 | equal( children.length, 2, 'parent is fetched with children' ); 800 | deepEqual( children.pluck('id'), ['foo1', 'foo2'], 'children are in the right order' ); 801 | }); 802 | 803 | QUnit.module( "Backbone.Relational.Model inheritance (`subModelTypes`)", { setup: require('./setup/setup').reset } ); 804 | 805 | QUnit.test( "Object building based on type, when using explicit collections" , function() { 806 | var scope = {}; 807 | Backbone.Relational.store.addModelScope( scope ); 808 | 809 | scope.Mammal = Animal.extend({ 810 | subModelTypes: { 811 | 'primate': 'Primate', 812 | 'carnivore': 'Carnivore', 813 | 'ape': 'Primate' // To check multiple keys for the same submodel; see GH-429 814 | } 815 | }); 816 | scope.Primate = scope.Mammal.extend({ 817 | subModelTypes: { 818 | 'human': 'Human' 819 | } 820 | }); 821 | scope.Human = scope.Primate.extend(); 822 | scope.Carnivore = scope.Mammal.extend(); 823 | 824 | var MammalCollection = AnimalCollection.extend({ 825 | model: scope.Mammal 826 | }); 827 | 828 | var mammals = new MammalCollection( [ 829 | { id: 5, species: 'chimp', type: 'primate' }, 830 | { id: 6, species: 'panther', type: 'carnivore' }, 831 | { id: 7, species: 'person', type: 'human' }, 832 | { id: 8, species: 'gorilla', type: 'ape' } 833 | ]); 834 | 835 | ok( mammals.at( 0 ) instanceof scope.Primate ); 836 | ok( mammals.at( 1 ) instanceof scope.Carnivore ); 837 | ok( mammals.at( 2 ) instanceof scope.Human ); 838 | ok( mammals.at( 3 ) instanceof scope.Primate ); 839 | }); 840 | 841 | QUnit.test( "Object building based on type, when used in relations" , function() { 842 | var scope = {}; 843 | Backbone.Relational.store.addModelScope( scope ); 844 | 845 | var PetAnimal = scope.PetAnimal = Backbone.Relational.Model.extend({ 846 | subModelTypes: { 847 | 'cat': 'Cat', 848 | 'dog': 'Dog' 849 | } 850 | }); 851 | var Dog = scope.Dog = PetAnimal.extend({ 852 | subModelTypes: { 853 | 'poodle': 'Poodle' 854 | } 855 | }); 856 | var Cat = scope.Cat = PetAnimal.extend(); 857 | var Poodle = scope.Poodle = Dog.extend(); 858 | 859 | var PetPerson = scope.PetPerson = Backbone.Relational.Model.extend({ 860 | relations: [{ 861 | type: Backbone.Relational.HasMany, 862 | key: 'pets', 863 | relatedModel: PetAnimal, 864 | reverseRelation: { 865 | key: 'owner' 866 | } 867 | }] 868 | }); 869 | 870 | var petPerson = new scope.PetPerson({ 871 | pets: [ 872 | { 873 | type: 'dog', 874 | name: 'Spot' 875 | }, 876 | { 877 | type: 'cat', 878 | name: 'Whiskers' 879 | }, 880 | { 881 | type: 'poodle', 882 | name: 'Mitsy' 883 | } 884 | ] 885 | }); 886 | 887 | ok( petPerson.get( 'pets' ).at( 0 ) instanceof Dog ); 888 | ok( petPerson.get( 'pets' ).at( 1 ) instanceof Cat ); 889 | ok( petPerson.get( 'pets' ).at( 2 ) instanceof Poodle ); 890 | 891 | petPerson.get( 'pets' ).add([{ 892 | type: 'dog', 893 | name: 'Spot II' 894 | },{ 895 | type: 'poodle', 896 | name: 'Mitsy II' 897 | }]); 898 | 899 | ok( petPerson.get( 'pets' ).at( 3 ) instanceof Dog ); 900 | ok( petPerson.get( 'pets' ).at( 4 ) instanceof Poodle ); 901 | }); 902 | 903 | QUnit.test( "Object building based on type in a custom field, when used in relations" , function() { 904 | var scope = {}; 905 | Backbone.Relational.store.addModelScope( scope ); 906 | 907 | var Caveman = scope.Caveman = Backbone.Relational.Model.extend({ 908 | subModelTypes: { 909 | 'rubble': 'Rubble', 910 | 'flintstone': 'Flintstone' 911 | }, 912 | subModelTypeAttribute: "caveman_type" 913 | }); 914 | var Flintstone = scope.Flintstone = Caveman.extend(); 915 | var Rubble = scope.Rubble = Caveman.extend(); 916 | 917 | var Cartoon = scope.Cartoon = Backbone.Relational.Model.extend({ 918 | relations: [{ 919 | type: Backbone.Relational.HasMany, 920 | key: 'cavemen', 921 | relatedModel: Caveman 922 | }] 923 | }); 924 | 925 | var captainCaveman = new scope.Cartoon({ 926 | cavemen: [ 927 | { 928 | type: 'rubble', 929 | name: 'CaptainCaveman' 930 | } 931 | ] 932 | }); 933 | 934 | ok( !(captainCaveman.get( "cavemen" ).at( 0 ) instanceof Rubble) ); 935 | 936 | var theFlintstones = new scope.Cartoon({ 937 | cavemen: [ 938 | { 939 | caveman_type: 'rubble', 940 | name: 'Barney' 941 | 942 | }, 943 | { 944 | caveman_type: 'flintstone', 945 | name: 'Wilma' 946 | } 947 | ] 948 | }); 949 | 950 | ok( theFlintstones.get( "cavemen" ).at( 0 ) instanceof Rubble ); 951 | ok( theFlintstones.get( "cavemen" ).at( 1 ) instanceof Flintstone ); 952 | 953 | }); 954 | 955 | QUnit.test( "Automatic sharing of 'superModel' relations" , function() { 956 | var scope = {}; 957 | Backbone.Relational.store.addModelScope( scope ); 958 | 959 | scope.PetPerson = Backbone.Relational.Model.extend({}); 960 | scope.PetAnimal = Backbone.Relational.Model.extend({ 961 | subModelTypes: { 962 | 'dog': 'Dog' 963 | }, 964 | 965 | relations: [{ 966 | type: Backbone.Relational.HasOne, 967 | key: 'owner', 968 | relatedModel: scope.PetPerson, 969 | reverseRelation: { 970 | type: Backbone.Relational.HasMany, 971 | key: 'pets' 972 | } 973 | }] 974 | }); 975 | 976 | scope.Flea = Backbone.Relational.Model.extend({}); 977 | 978 | scope.Dog = scope.PetAnimal.extend({ 979 | subModelTypes: { 980 | 'poodle': 'Poodle' 981 | }, 982 | 983 | relations: [{ 984 | type: Backbone.Relational.HasMany, 985 | key: 'fleas', 986 | relatedModel: scope.Flea, 987 | reverseRelation: { 988 | key: 'host' 989 | } 990 | }] 991 | }); 992 | scope.Poodle = scope.Dog.extend(); 993 | 994 | var dog = new scope.Dog({ 995 | name: 'Spot' 996 | }); 997 | 998 | var poodle = new scope.Poodle({ 999 | name: 'Mitsy' 1000 | }); 1001 | 1002 | var person = new scope.PetPerson({ 1003 | pets: [ dog, poodle ] 1004 | }); 1005 | 1006 | ok( dog.get( 'owner' ) === person, "Dog has a working owner relation." ); 1007 | ok( poodle.get( 'owner' ) === person, "Poodle has a working owner relation." ); 1008 | 1009 | var flea = new scope.Flea({ 1010 | host: dog 1011 | }); 1012 | 1013 | var flea2 = new scope.Flea({ 1014 | host: poodle 1015 | }); 1016 | 1017 | ok( dog.get( 'fleas' ).at( 0 ) === flea, "Dog has a working fleas relation." ); 1018 | ok( poodle.get( 'fleas' ).at( 0 ) === flea2, "Poodle has a working fleas relation." ); 1019 | }); 1020 | 1021 | QUnit.test( "Initialization and sharing of 'superModel' reverse relations from a 'leaf' child model" , function() { 1022 | var scope = {}; 1023 | Backbone.Relational.store.addModelScope( scope ); 1024 | scope.PetAnimal = Backbone.Relational.Model.extend({ 1025 | subModelTypes: { 1026 | 'dog': 'Dog' 1027 | } 1028 | }); 1029 | 1030 | scope.Flea = Backbone.Relational.Model.extend({}); 1031 | scope.Dog = scope.PetAnimal.extend({ 1032 | subModelTypes: { 1033 | 'poodle': 'Poodle' 1034 | }, 1035 | relations: [{ 1036 | type: Backbone.Relational.HasMany, 1037 | key: 'fleas', 1038 | relatedModel: scope.Flea, 1039 | reverseRelation: { 1040 | key: 'host' 1041 | } 1042 | }] 1043 | }); 1044 | scope.Poodle = scope.Dog.extend(); 1045 | 1046 | // Define the PetPerson after defining all of the Animal models. Include the 'owner' as a reverse-relation. 1047 | scope.PetPerson = Backbone.Relational.Model.extend({ 1048 | relations: [{ 1049 | type: Backbone.Relational.HasMany, 1050 | key: 'pets', 1051 | relatedModel: scope.PetAnimal, 1052 | reverseRelation: { 1053 | type: Backbone.Relational.HasOne, 1054 | key: 'owner' 1055 | } 1056 | }] 1057 | }); 1058 | 1059 | // Initialize the models starting from the deepest descendant and working your way up to the root parent class. 1060 | var poodle = new scope.Poodle({ 1061 | name: 'Mitsy' 1062 | }); 1063 | 1064 | var dog = new scope.Dog({ 1065 | name: 'Spot' 1066 | }); 1067 | 1068 | var person = new scope.PetPerson({ 1069 | pets: [ dog, poodle ] 1070 | }); 1071 | 1072 | ok( dog.get( 'owner' ) === person, "Dog has a working owner relation." ); 1073 | ok( poodle.get( 'owner' ) === person, "Poodle has a working owner relation." ); 1074 | 1075 | var flea = new scope.Flea({ 1076 | host: dog 1077 | }); 1078 | 1079 | var flea2 = new scope.Flea({ 1080 | host: poodle 1081 | }); 1082 | 1083 | ok( dog.get( 'fleas' ).at( 0 ) === flea, "Dog has a working fleas relation." ); 1084 | ok( poodle.get( 'fleas' ).at( 0 ) === flea2, "Poodle has a working fleas relation." ); 1085 | }); 1086 | 1087 | QUnit.test( "Initialization and sharing of 'superModel' reverse relations by adding to a polymorphic HasMany" , function() { 1088 | var scope = {}; 1089 | Backbone.Relational.store.addModelScope( scope ); 1090 | scope.PetAnimal = Backbone.Relational.Model.extend({ 1091 | // The order in which these are defined matters for this regression test. 1092 | subModelTypes: { 1093 | 'dog': 'Dog', 1094 | 'fish': 'Fish' 1095 | } 1096 | }); 1097 | 1098 | // This looks unnecessary but it's for this regression test there has to be multiple subModelTypes. 1099 | scope.Fish = scope.PetAnimal.extend({}); 1100 | 1101 | scope.Flea = Backbone.Relational.Model.extend({}); 1102 | scope.Dog = scope.PetAnimal.extend({ 1103 | subModelTypes: { 1104 | 'poodle': 'Poodle' 1105 | }, 1106 | relations: [{ 1107 | type: Backbone.Relational.HasMany, 1108 | key: 'fleas', 1109 | relatedModel: scope.Flea, 1110 | reverseRelation: { 1111 | key: 'host' 1112 | } 1113 | }] 1114 | }); 1115 | scope.Poodle = scope.Dog.extend({}); 1116 | 1117 | // Define the PetPerson after defining all of the Animal models. Include the 'owner' as a reverse-relation. 1118 | scope.PetPerson = Backbone.Relational.Model.extend({ 1119 | relations: [{ 1120 | type: Backbone.Relational.HasMany, 1121 | key: 'pets', 1122 | relatedModel: scope.PetAnimal, 1123 | reverseRelation: { 1124 | type: Backbone.Relational.HasOne, 1125 | key: 'owner' 1126 | } 1127 | }] 1128 | }); 1129 | 1130 | // We need to initialize a model through the root-parent-model's build method by adding raw-attributes for a 1131 | // leaf-child-class to a polymorphic HasMany. 1132 | var person = new scope.PetPerson({ 1133 | pets: [{ 1134 | type: 'poodle', 1135 | name: 'Mitsy' 1136 | }] 1137 | }); 1138 | var poodle = person.get('pets').first(); 1139 | ok( poodle.get( 'owner' ) === person, "Poodle has a working owner relation." ); 1140 | }); 1141 | 1142 | QUnit.test( "Overriding of supermodel relations", function() { 1143 | var models = {}; 1144 | Backbone.Relational.store.addModelScope( models ); 1145 | 1146 | models.URL = Backbone.Relational.Model.extend({}); 1147 | 1148 | models.File = Backbone.Relational.Model.extend({ 1149 | subModelTypes: { 1150 | 'video': 'Video', 1151 | 'publication': 'Publication' 1152 | }, 1153 | 1154 | relations: [{ 1155 | type: Backbone.Relational.HasOne, 1156 | key: 'url', 1157 | relatedModel: models.URL 1158 | }] 1159 | }); 1160 | 1161 | models.Video = models.File.extend({}); 1162 | 1163 | // Publication redefines the `url` relation 1164 | models.Publication = Backbone.Relational.Model.extend({ 1165 | relations: [{ 1166 | type: Backbone.Relational.HasMany, 1167 | key: 'url', 1168 | relatedModel: models.URL 1169 | }] 1170 | }); 1171 | 1172 | models.Project = Backbone.Relational.Model.extend({ 1173 | relations: [{ 1174 | type: Backbone.Relational.HasMany, 1175 | key: 'files', 1176 | relatedModel: models.File, 1177 | reverseRelation: { 1178 | key: 'project' 1179 | } 1180 | }] 1181 | }); 1182 | 1183 | equal( models.File.prototype.relations.length, 2, "2 relations on File" ); 1184 | equal( models.Video.prototype.relations.length, 1, "1 relation on Video" ); 1185 | equal( models.Publication.prototype.relations.length, 1, "1 relation on Publication" ); 1186 | 1187 | // Instantiating the superModel should instantiate the modelHierarchy, and copy relations over to subModels 1188 | var file = new models.File(); 1189 | 1190 | equal( models.File.prototype.relations.length, 2, "2 relations on File" ); 1191 | equal( models.Video.prototype.relations.length, 2, "2 relations on Video" ); 1192 | equal( models.Publication.prototype.relations.length, 2, "2 relations on Publication" ); 1193 | 1194 | var projectDecription = { 1195 | name: 'project1', 1196 | 1197 | files: [ 1198 | { 1199 | name: 'file1 - video subclass', 1200 | type: 'video', 1201 | url: { 1202 | location: 'http://www.myurl.com/file1.avi' 1203 | } 1204 | }, 1205 | { 1206 | name: 'file2 - file baseclass', 1207 | url: { 1208 | location: 'http://www.myurl.com/file2.jpg' 1209 | } 1210 | }, 1211 | { 1212 | name: 'file3 - publication', 1213 | type: 'publication', 1214 | url: [ 1215 | { location: 'http://www.myurl.com/file3.pdf' }, 1216 | { location: 'http://www.anotherurl.com/file3.doc' } 1217 | ] 1218 | } 1219 | ] 1220 | }; 1221 | 1222 | var project = new models.Project( projectDecription ), 1223 | files = project.get( 'files' ), 1224 | file1 = files.at( 0 ), 1225 | file2 = files.at( 1 ), 1226 | file3 = files.at( 2 ); 1227 | 1228 | equal( models.File.prototype.relations.length, 2, "2 relations on File" ); 1229 | equal( models.Video.prototype.relations.length, 2, "2 relations on Video" ); 1230 | equal( models.Publication.prototype.relations.length, 2, "2 relations on Publication" ); 1231 | 1232 | equal( _.size( file1._relations ), 2 ); 1233 | equal( _.size( file2._relations ), 2 ); 1234 | equal( _.size( file3._relations ), 2 ); 1235 | 1236 | ok( file1.get( 'url' ) instanceof Backbone.Model, '`url` on Video is a model' ); 1237 | ok( file1.getRelation( 'url' ) instanceof Backbone.Relational.HasOne, '`url` relation on Video is HasOne' ); 1238 | 1239 | ok( file3.get( 'url' ) instanceof Backbone.Relational.Collection, '`url` on Publication is a collection' ); 1240 | ok( file3.getRelation( 'url' ) instanceof Backbone.Relational.HasMany, '`url` relation on Publication is HasMany' ); 1241 | }); 1242 | 1243 | QUnit.test( "toJSON includes the type", function() { 1244 | var scope = {}; 1245 | Backbone.Relational.store.addModelScope( scope ); 1246 | 1247 | scope.PetAnimal = Backbone.Relational.Model.extend({ 1248 | subModelTypes: { 1249 | 'dog': 'Dog' 1250 | } 1251 | }); 1252 | 1253 | scope.Dog = scope.PetAnimal.extend(); 1254 | 1255 | var dog = new scope.Dog({ 1256 | name: 'Spot' 1257 | }); 1258 | 1259 | var json = dog.toJSON(); 1260 | 1261 | equal( json.type, 'dog', "The value of 'type' is the pet animal's type." ); 1262 | }); 1263 | --------------------------------------------------------------------------------