├── .gitignore ├── .jshintrc ├── .travis.yml ├── Brocfile.js ├── CONTRIBUTING.md ├── Changelog.md ├── LICENSE ├── Makefile ├── README.md ├── bower.json ├── dist ├── ember-resource.js └── ember-resource.min.js ├── package-lock.json ├── package.json ├── spec ├── javascripts │ ├── ajaxSpec.js │ ├── associationsSpec.js │ ├── deepMergeSpec.js │ ├── deepSetSpec.js │ ├── destroySpec.js │ ├── fetchSpec.js │ ├── findAndExpireSpec.js │ ├── identityMapSpec.js │ ├── inheritanceSpec.js │ ├── lifecycleSpec.js │ ├── lookUpTypeSpec.js │ ├── remoteExpirySpec.js │ ├── resourceCollectionSpec.js │ ├── resourceSpec.js │ ├── resourceURLSpec.js │ ├── saveSpec.js │ ├── schemaSpec.js │ └── toJSONSpec.js ├── runner-next.html ├── runner.html └── test_helper.js ├── src ├── base.js ├── debug_adapter.js ├── ember-resource.js ├── fetch.js ├── identity_map.js ├── remote_expiry.js └── vendor │ └── lru.js └── vendor ├── ember-1.10.1.js ├── ember-1.13.13.js ├── jquery-1.10.2.js ├── sinon-1.2.0.js └── sinon.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /tmp 3 | /bower_components 4 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ 3 | "$", "Ember", "Em", "_", 4 | "describe", "beforeEach", "afterEach", "it", "expect", "sinon" 5 | ], 6 | 7 | "bitwise": true, 8 | "newcap": false, 9 | "eqeqeq": false, 10 | "eqnull": true, 11 | "immed": true, 12 | "nomen": false, 13 | "onevar": false, 14 | "plusplus": false, 15 | "regexp": false, 16 | "strict": false, 17 | "undef": true, 18 | "white": false, 19 | 20 | "debug": false, 21 | "es5": false, 22 | "evil": false, 23 | "forin": false, 24 | "laxbreak": false, 25 | "sub": false, 26 | 27 | "maxlen": 200, 28 | "indent": 2, 29 | "maxerr": 50, 30 | "passfail": false, 31 | "browser": true, 32 | "rhino": false, 33 | "devel": true, 34 | "lastsemic": false, 35 | "expr": true 36 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: "node_js" 2 | dist: xenial 3 | node_js: 4 | - 12.10.0 5 | services: 6 | - xvfb 7 | before_script: 8 | - export DISPLAY=:99.0 9 | script: make ci 10 | -------------------------------------------------------------------------------- /Brocfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(broccoli) { 2 | var concatenate = require('broccoli-concat'), 3 | uglify = require('broccoli-uglify-js'), 4 | mergeTrees = require('broccoli-merge-trees'); 5 | 6 | var sourceTree = 'src'; 7 | 8 | var concatenated = concatenate(sourceTree, { 9 | inputFiles: [ 10 | 'vendor/lru.js', 11 | 'base.js', 12 | 'remote_expiry.js', 13 | 'identity_map.js', 14 | 'fetch.js', 15 | 'ember-resource.js', 16 | 'debug_adapter.js' 17 | ], 18 | outputFile: '/ember-resource.js' 19 | }); 20 | 21 | var minified = uglify(concatenated, { 22 | targetExtension: 'min.js' 23 | }); 24 | 25 | return mergeTrees([concatenated, minified]); 26 | }; 27 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Ember Resource 2 | 3 | We're glad you're interested in contributing to the Ember Resource project! There are a number of ways in which you can contribute! 4 | 5 | * Submit issues to our [issue tracker][1] 6 | * Use Ember Resource in your own project and let us know how it works for you 7 | * Open a pull request and contribute some of your own code 8 | 9 | ### Pull Requests 10 | 11 | We love pull requests. Please make sure to run the tests locally (`open spec/runner.html` for browser tests or `make test` on the command line) before pushing your branch . But don't worry, even if you forget [Travis CI][2] should run the tests on the pull request for you. 12 | 13 | Submit your pull request! 14 | 15 | [1]: https://github.com/zendesk/ember-resource/issues 16 | [2]: https://travis-ci.org/zendesk/ember-resource/ 17 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | ## 2.3.10 -- 2020-05-04 2 | 3 | * Pass down the save's and refetchOnExpiry's options to ajax (#129) 4 | 5 | ## 2.3.9 -- 2020-04-29 6 | 7 | * Adding a `refetchOnExpiry` method to be overridable in subclasses (#128) 8 | 9 | ## 2.3.8 -- 2020-04-16 10 | 11 | * Remove Ember listeners to prevent memory leaks (#127) 12 | 13 | ## 2.3.7 -- 2020-03-17 14 | 15 | * Pass returned data from save to didSave (#126) 16 | 17 | ## 2.3.6 -- 2020-02-28 18 | 19 | * Only remove a resource from IdentityMap when it is the same instance (#125) 20 | 21 | ## 2.3.5 -- 2016-16-14 22 | 23 | * Fix for regression introduced in v2.3.3 (#123) 24 | 25 | ## 2.3.4 -- 2016-06-13 26 | 27 | * Safe usage of Ember.beginPropertyChanges/Ember.endPropertyChanges (#122) 28 | * Publishing to artifactory under @zendesk scope (#121) 29 | 30 | ## 2.3.3 -- 2016-11-17 31 | 32 | * Allow specifying specific fields to be sent over the network when saving a resource (#120) 33 | 34 | ## 2.3.2 -- 2016-03-05 35 | 36 | * Add support for Ember 1.13+ (#117) 37 | 38 | ## 2.3.1 -- 2015-12-02 39 | * Fix a bug where an Ember assertion is failed if an object is destroyed or marked for destruction after a call to fetch but before the fetch completes. 40 | 41 | ## 2.3.0 -- 2015-23-11 42 | * Add Support for `abortCallback` Option in Em.Resource.ajax (#113) 43 | 44 | ## 2.1.0 -- 2015-15-10 45 | * Adds a handler for LRU evictions (#109) 46 | * Ember.Resource.save() should always return a promise (#108) 47 | * Fixed Ember 1.12 deprecation warnings (#105) 48 | 49 | ## 2.1.0 -- 2015-05-08 50 | 51 | * Adds a hasBeenFetched property to resources and collections 52 | * ResourceCollection supports primitive JS types for items 53 | * Removed `cacheable` on schema generated properties 54 | 55 | ## 2.0.1 -- 2014-07-28 56 | 57 | * `Ember.Resource#fetch` always returns a Promise, even if `resourceURL()` returns `undefined` 58 | 59 | ## 2.0 -- 2014-04-02 60 | 61 | * Disable the Ember Resource Clock by default 62 | * Add Ember `1.x` support 63 | 64 | ## 1.0 -- 2013-02-26 65 | 66 | * Initial release 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DIST_JS = dist/ember-resource.js 2 | 3 | BROCCOLI = ./node_modules/broccoli-cli/bin/broccoli 4 | JSHINT = ./node_modules/jshint/bin/jshint 5 | MOCHA-CHROME = ./node_modules/.bin/mocha-chrome 6 | 7 | dist: $(DIST_JS) $(JSHINT) 8 | @$(JSHINT) $< 9 | 10 | ci: dist test 11 | 12 | $(DIST_JS): jshint $(BROCCOLI) 13 | @rm -rf dist 14 | $(BROCCOLI) build dist 15 | @echo "\n Build successful!\n" 16 | 17 | jshint: $(JSHINT) 18 | @$(JSHINT) src/*.js src/vendor/*.js spec/javascripts/*Spec.js 19 | 20 | test: test-ember-current test-ember-next 21 | 22 | test-ember-current: jshint $(MOCHA-CHROME) 23 | $(MOCHA-CHROME) spec/runner.html 24 | 25 | test-ember-next: jshint $(MOCHA-CHROME) 26 | $(MOCHA-CHROME) spec/runner-next.html 27 | 28 | $(BROCCOLI): npm_install 29 | $(JSHINT): npm_install 30 | $(MOCHA-CHROME): npm_install 31 | 32 | npm_install: 33 | npm install > /dev/null 34 | 35 | clean: 36 | rm -rf ./dist 37 | 38 | clobber: clean 39 | rm -rf ./node_modules ./tmp 40 | 41 | .PHONY: dist ci jshint test test-ember-09 test-ember-1 npm_install clean clobber 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ember-Resource 2 | 3 | A simple library to connect your Ember.js application to JSON backends. 4 | 5 | * Current stable version: `2.3.10` 6 | * Download: \[[Development version][1]] \[[Minified version][2]] 7 | * CDN: `//cdnjs.cloudflare.com/ajax/libs/ember-resource.js/2.3.10/ember-resource.min.js` 8 | 9 | ## Ember Resource 2.0 10 | 11 | Notable `2.0` features: 12 | 13 | * The Ember Resource Clock is not enabled by default -- this has been known to cause performance problems in large applications. 14 | * Ember Resource `2.0` has `Ember 1.x` support. 15 | 16 | ## The Mandatory Todo Application 17 | 18 | I've created a modified version of the todo application that the Ember.js Tutorial walks you through. 19 | https://github.com/staugaard/sproutcore-resource-todos 20 | This version persists the todo items on the server using a very small sinatra application and MongoDB. 21 | 22 | ## Examples 23 | 24 | We will provide you with some documentation and stuff, but for now here's a little inspiration: 25 | 26 | Think about running Wordpress.org. This is the schema you would use: 27 | 28 | Assuming that /users/1 returns this JSON: 29 | 30 | ```javascript 31 | { 32 | id: 1, 33 | name: "Mick Staugaard" 34 | } 35 | ``` 36 | 37 | You would use this user model: 38 | 39 | ```javascript 40 | MyApp.User = Ember.Resource.define({ 41 | url: '/users', 42 | schema: { 43 | id: Number, 44 | name: String, 45 | blogs: { 46 | type: Ember.ResourceCollection, 47 | itemType: 'MyApp.Blog', 48 | url: '/users/%@/blogs' 49 | } 50 | } 51 | }); 52 | ``` 53 | 54 | Assuming that /blogs/1 returns this JSON: 55 | 56 | ```javascript 57 | { 58 | id: 1, 59 | name: "My awesome blog", 60 | owner_id: 1 61 | } 62 | ``` 63 | 64 | You would use this blog model: 65 | 66 | ```javascript 67 | MyApp.Blog = Ember.Resource.define({ 68 | url: '/blogs' 69 | schema: { 70 | id: Number, 71 | name: String, 72 | owner: { 73 | type: MyApp.User 74 | }, 75 | posts: { 76 | type: Ember.ResourceCollection, 77 | itemType: 'MyApp.Post', 78 | url: '/blogs/%@/posts' 79 | } 80 | } 81 | }); 82 | ``` 83 | 84 | Assuming that /posts/1 returns this JSON: 85 | 86 | ```javascript 87 | { 88 | id: 1, 89 | title: "Welcome to the blog", 90 | body: "OMG I started a blog!", 91 | blog_id: 1 92 | } 93 | ``` 94 | 95 | You would use this post model: 96 | 97 | ```javascript 98 | MyApp.Post = Ember.Resource.define({ 99 | url: '/posts', 100 | schema: { 101 | id: Number, 102 | title: String, 103 | body: String, 104 | blog: { 105 | type: MyApp.Blog 106 | }, 107 | comments: { 108 | type: Ember.ResourceCollection, 109 | itemType: 'MyApp.Comment', 110 | url: '/posts/%@/comments' 111 | } 112 | } 113 | }); 114 | ``` 115 | 116 | Assuming that /comments/1 returns this JSON: 117 | 118 | ```javascript 119 | { 120 | id: 1, 121 | body: "I have something constructive to say.", 122 | post_id: 1, 123 | author: { 124 | id: 2, 125 | name: "Comment Author" 126 | } 127 | } 128 | ``` 129 | 130 | You would use this comment model: 131 | 132 | ```javascript 133 | MyApp.Comment = Ember.Resource.define({ 134 | url: '/comments', 135 | schema: { 136 | id: Number, 137 | body: String, 138 | post: { 139 | type: MyApp.Post 140 | }, 141 | author: { 142 | type: MyApp.User, 143 | nested: true 144 | } 145 | } 146 | }); 147 | ``` 148 | 149 | ### Fetching, Saving, and Destroying 150 | 151 | Fetch a resource with `fetch`: 152 | 153 | ```javascript 154 | MyApp.Comment = Ember.Resource.define({...}); 155 | MyApp.Comment.create({ id: 13 }).fetch(); 156 | ``` 157 | 158 | Calling `fetch` will issue an AJAX request to the resource's URL. It will 159 | return a [promise](http://api.jquery.com/category/deferred-object/). If the 160 | AJAX request responds normally, the promise will resolve with the API response 161 | and the resource. If it fails, the promised will fail with the AJAX error. 162 | 163 | The success callbacks for `save` and `destroyResource` have a slightly 164 | different signature. Those deferreds resolve with the resource and a String 165 | describing the action that occurred (one of 166 | `[ "create", "update", "destroy" ]`). 167 | 168 | Note about `destroyResource`: when you destroy a resource, the Em.Resource 169 | instance in memory is also `destroy`-ed, in that `.destroy()` is called on 170 | it. This action is, however, deferred to the next run-loop after the AJAX 171 | callbacks run. This is to allow any UI behavior that requires to access this 172 | Em.Resource instance to work without any errors. 173 | 174 | 175 | ## Testing 176 | 177 | [![Travis CI Build Status](https://api.travis-ci.org/zendesk/ember-resource.svg)](https://travis-ci.org/zendesk/ember-resource) 178 | 179 | Tests can be run from the command line, or in a browser: 180 | 181 | ### Browser 182 | 183 | To run the Ember Resource test suite in a browser, just open `spec/runner.html` 184 | in your favorite browser. 185 | 186 | On Mac OS: 187 | 188 | open spec/runner.html 189 | 190 | ### Command line 191 | 192 | To run the test suite from the command line run the `make test` command: 193 | 194 | make test 195 | 196 | ## Building a distribution 197 | 198 | To build your very own copy of Ember Resource run the `make dist` task: 199 | 200 | make dist 201 | 202 | The output will be put in the `dist/` folder. 203 | 204 | ## Copyright and license 205 | 206 | Copyright 2013 Zendesk 207 | 208 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. 209 | You may obtain a copy of the License at 210 | 211 | http://www.apache.org/licenses/LICENSE-2.0 212 | 213 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 214 | 215 | [1]: https://cdnjs.cloudflare.com/ajax/libs/ember-resource.js/2.3.10/ember-resource.js 216 | [2]: https://cdnjs.cloudflare.com/ajax/libs/ember-resource.js/2.3.10/ember-resource.min.js 217 | [3]: http://semver.org/ 218 | [4]: https://github.com/zendesk/ember-resource/tree/2-0-stable 219 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-resource", 3 | "version": "2.3.10", 4 | "homepage": "https://github.com/zendesk/ember-resource", 5 | "description": "A simple library to connect your Ember.js application to JSON backends.", 6 | "main": "dist/ember-resource.js", 7 | "keywords": [ 8 | "ember", 9 | "json", 10 | "ajax", 11 | "orm" 12 | ], 13 | "authors": [ 14 | "Zendesk Developers " 15 | ], 16 | "license": "APLv2", 17 | "ignore": [ 18 | "bower_components", 19 | "spec", 20 | "src", 21 | "vendor", 22 | "CONTRIBUTING.md", 23 | "Makefile", 24 | "package.json" 25 | ], 26 | "dependencies": { 27 | "jquery": ">=1.7 <3", 28 | "ember": "1.x" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /dist/ember-resource.min.js: -------------------------------------------------------------------------------- 1 | function LRUCache(limit){this.size=0;this.limit=limit;this._keymap={}}LRUCache.prototype.put=function(key,value){var entry={key:key,value:value};this._keymap[key]=entry;if(this.tail){this.tail.newer=entry;entry.older=this.tail}else{this.head=entry}this.tail=entry;if(this.size===this.limit){return this.shift()}else{this.size++}};LRUCache.prototype.shift=function(){var entry=this.head;if(entry){if(this.head.newer){this.head=this.head.newer;this.head.older=undefined}else{this.head=undefined}entry.newer=entry.older=undefined;delete this._keymap[entry.key]}return entry};LRUCache.prototype.get=function(key,returnEntry){var entry=this._keymap[key];if(entry===undefined)return;if(entry===this.tail){return entry.value}if(entry.newer){if(entry===this.head)this.head=entry.newer;entry.newer.older=entry.older}if(entry.older)entry.older.newer=entry.newer;entry.newer=undefined;entry.older=this.tail;if(this.tail)this.tail.newer=entry;this.tail=entry;return returnEntry?entry:entry.value};LRUCache.prototype.find=function(key){return this._keymap[key]};LRUCache.prototype.set=function(key,value){var oldvalue,entry=this.get(key,true);if(entry){oldvalue=entry.value;entry.value=value}else{oldvalue=this.put(key,value);if(oldvalue)oldvalue=oldvalue.value}return oldvalue};LRUCache.prototype.remove=function(key){var entry=this._keymap[key];if(!entry)return;delete this._keymap[entry.key];if(entry.newer&&entry.older){entry.older.newer=entry.newer;entry.newer.older=entry.older}else if(entry.newer){entry.newer.older=undefined;this.head=entry.newer}else if(entry.older){entry.older.newer=undefined;this.tail=entry.older}else{this.head=this.tail=undefined}this.size--;return entry.value};LRUCache.prototype.removeAll=function(){this.head=this.tail=undefined;this.size=0;this._keymap={}};if(typeof Object.keys==="function"){LRUCache.prototype.keys=function(){return Object.keys(this._keymap)}}else{LRUCache.prototype.keys=function(){var keys=[];for(var k in this._keymap)keys.push(k);return keys}}LRUCache.prototype.forEach=function(fun,context,desc){var entry;if(context===true){desc=true;context=undefined}else if(typeof context!=="object")context=this;if(desc){entry=this.tail;while(entry){fun.call(context,entry.key,entry.value,this);entry=entry.older}}else{entry=this.head;while(entry){fun.call(context,entry.key,entry.value,this);entry=entry.newer}}};LRUCache.prototype.toJSON=function(){var s=[],entry=this.head;while(entry){s.push({key:entry.key.toJSON(),value:entry.value.toJSON()});entry=entry.newer}return s};LRUCache.prototype.toString=function(){var s="",entry=this.head;while(entry){s+=String(entry.key)+":"+entry.value;entry=entry.newer;if(entry)s+=" < "}return s};if(typeof this==="object")this.LRUCache=LRUCache;(function(){window.Ember=window.Ember||window.SC;window.Ember.Resource=window.Ember.Object.extend({resourcePropertyWillChange:window.Ember.K,resourcePropertyDidChange:window.Ember.K});window.Ember.Resource.getPath=function(){var o={object:{path:"value"}},getSupportsPath=Ember.get(o,"object.path")==="value";return getSupportsPath?Ember.get:Ember.getPath}();window.Ember.Resource.sendEvent=function(){if(Ember.sendEvent.length===2){return function sendEvent(obj,eventName,params,actions){Ember.warn("Ember.Resources.sendEvent can't do anything with actions on Ember 0.9",!actions);params=params||[];params.unshift(eventName);params.unshift(obj);return Ember.sendEvent.apply(Ember,params)}}return function sendEvent(obj,eventName,params,actions){return Ember.sendEvent(obj,eventName,params,actions)}}()})();(function(exports){var Ember=exports.Ember,NullTransport={subscribe:Ember.K,unsubscribe:Ember.K};Ember.Resource.PushTransport=NullTransport;var RemoteExpiry=Ember.Mixin.create({init:function(){var self=this;this._super();this._expiryCallback=null;if(this.get("remoteExpiryKey")){this._listenerHandlers={didFetch:function(){this.subscribeForExpiry()}};Ember.addListener(this,"didFetch",this,this._listenerHandlers.didFetch)}},subscribeForExpiry:function(){var remoteExpiryScope=this.get("remoteExpiryKey");if(!remoteExpiryScope){return}if(this._expiryCallback){return}this._expiryCallback=this.updateExpiry.bind(this);Ember.Resource.PushTransport.subscribe(remoteExpiryScope,this._expiryCallback)},willDestroy:function(){var remoteExpiryScope=this.get("remoteExpiryKey");if(this._listenerHandlers){Ember.removeListener(this,"didFetch",this,this._listenerHandlers.didFetch)}if(!remoteExpiryScope){return}if(!this._expiryCallback){return}Ember.Resource.PushTransport.unsubscribe(remoteExpiryScope,this._expiryCallback);this._super&&this._super()},refetchOnExpiry:function(options){this.expireNow();this.fetch(options)},updateExpiry:function(message){var updatedAt=message&&message.updatedAt;if(!updatedAt)return;if(this.stale(updatedAt)){this.set("expiryUpdatedAt",updatedAt);if(this.get("remoteExpiryAutoFetch")){this.refetchOnExpiry()}else{this.expire()}}},stale:function(updatedAt){return!this.get("expiryUpdatedAt")||+this.get("expiryUpdatedAt")<+updatedAt}});Ember.Resource.RemoteExpiry=RemoteExpiry})(this);(function(){Ember.Resource.IdentityMap=function(limit,evictionHandler){this.cache=new LRUCache(limit||Ember.Resource.IdentityMap.DEFAULT_IDENTITY_MAP_LIMIT);this.evictionHandler=evictionHandler||function(){};var map=this,origShift=this.cache.shift;this.cache.shift=function(){var entry=origShift.apply(this,arguments);map.evictionHandler(entry);return entry}};Ember.Resource.IdentityMap.prototype={get:function(){return LRUCache.prototype.get.apply(this.cache,arguments)},put:function(){return LRUCache.prototype.put.apply(this.cache,arguments)},remove:function(){return LRUCache.prototype.remove.apply(this.cache,arguments)},clear:function(){return LRUCache.prototype.removeAll.apply(this.cache,arguments)},size:function(){return this.cache.size},limit:function(){return this.cache.limit}};Ember.Resource.IdentityMap.DEFAULT_IDENTITY_MAP_LIMIT=500})();(function(exports){var errorHandlerWithContext=function(errorHandler,context){return function(){var args=Array.prototype.slice.call(arguments,0);args.push(context);errorHandler.apply(context,args)}};var slice=Array.prototype.slice;exports.Ember.Resource.ajax=function(options){if(typeof options==="string"){options={url:""+options}}options.dataType=options.dataType||"json";options.type=options.type||"GET";if(options.error){options.error=errorHandlerWithContext(options.error,options)}else if(exports.Ember.Resource.errorHandler){options.error=errorHandlerWithContext(window.Ember.Resource.errorHandler,options)}var dfd=$.Deferred();var ajax=$.ajax(options).done(function(){var args=slice.apply(arguments);Em.run(function(){dfd.resolveWith(options.context,args)})}).fail(function(){var args=slice.apply(arguments);Em.run(function(){dfd.rejectWith(options.context,args)})});if(options.abortCallback){options.abortCallback(ajax.abort.bind(ajax))}return dfd.promise()};exports.Em.Resource.fetch=function(resource){return Em.Resource.ajax.apply(Em.Resource,slice.call(arguments,1))}})(this);(function(exports){var expandSchema,expandSchemaItem,createSchemaProperties,mergeSchemas;var Ember=exports.Ember,getPath=Ember.Resource.getPath,set=Ember.set;var requiresEmberComputedPropertyFunction=false;try{Ember.computed({get:function(){},set:function(key,value){}})}catch(ex){requiresEmberComputedPropertyFunction=true}function isString(obj){return Ember.typeOf(obj)==="string"}function isNumber(obj){return Ember.typeOf(obj)==="number"}function isBoolean(obj){return Ember.typeOf(obj)==="boolean"}function isObject(obj){return Ember.typeOf(obj)==="object"}function isFunction(obj){return Ember.typeOf(obj)==="function"}Ember.Resource.lookUpType=function(string){return getPath(exports,string)};Ember.Resource.deepSet=function(obj,path,value){if(isString(path)){Ember.Resource.deepSet(obj,path.split("."),value);return}var key=path.shift();if(path.length===0){if(isObject(value)){set(obj,key,Em.copy(value,true))}else{set(obj,key,value)}}else{var newObj=getPath(obj,key);if(newObj===null||newObj===undefined){newObj={};set(obj,key,newObj)}Ember.propertyWillChange(newObj,path);Ember.Resource.deepSet(newObj,path,value);Ember.propertyDidChange(newObj,path)}};Ember.Resource.deepMerge=function(objA,objB){var oldValue,newValue;for(var key in objB){if(objB.hasOwnProperty(key)){oldValue=getPath(objA,key);newValue=getPath(objB,key);if(isObject(newValue)&&isObject(oldValue)){Ember.propertyWillChange(objA,key);Ember.Resource.deepMerge(oldValue,newValue);Ember.propertyDidChange(objA,key)}else{set(objA,key,newValue)}}}};Ember.Resource.AbstractSchemaItem=Ember.Object.extend({name:String,getValue:Function,setValue:Function,dependencies:Ember.computed("path",function(){var deps=["data."+this.get("path")];return deps}),data:function(instance){return getPath(instance,"data")},type:Ember.computed("theType",function(){var type=this.get("theType");if(isString(type)){type=Ember.Resource.lookUpType(type);if(type){this.set("theType",type)}else{type=this.get("theType")}}return type}),propertyFunction:function(){var _get=function(name){var schemaItem=this.constructor.schema[name];return schemaItem.getValue.call(schemaItem,this)},_set=function(name,value){var schemaItem=this.constructor.schema[name];this.resourcePropertyWillChange(name,value);schemaItem.setValue.call(schemaItem,this,value);value=schemaItem.getValue.call(schemaItem,this);this.resourcePropertyDidChange(name,value);return value};return requiresEmberComputedPropertyFunction?function(name,value){if(arguments.length>1){return _set.apply(this,arguments)}else{return _get.apply(this,arguments)}}:{get:_get,set:_set}}(),property:function(){var cp=new Ember.ComputedProperty(this.propertyFunction);return cp.property.apply(cp,this.get("dependencies"))},toJSON:function(instance){return undefined}});Ember.Resource.AbstractSchemaItem.reopenClass({create:function(name,schema){return this._super({name:name})}});Ember.Resource.SchemaItem=Ember.Resource.AbstractSchemaItem.extend({});Ember.Resource.SchemaItem.reopenClass({create:function(name,schema){var definition=schema[name];if(definition instanceof Ember.Resource.AbstractSchemaItem){return definition}var type;if(definition===Number||definition===String||definition===Boolean||definition===Date||definition===Object){definition={type:definition};schema[name]=definition}if(isObject(definition)){type=definition.type}if(type){if(type.isEmberResource||isString(type)){return Ember.Resource.HasOneSchemaItem.create(name,schema)}else if(type.isEmberResourceCollection){return Ember.Resource.HasManySchemaItem.create(name,schema)}else{return Ember.Resource.AttributeSchemaItem.create(name,schema)}}}});Ember.Resource.AttributeSchemaItem=Ember.Resource.AbstractSchemaItem.extend({theType:Object,path:String,getValue:function(instance){var value;var data=this.data(instance);if(data){value=getPath(data,this.get("path"))}if(this.typeCast){value=this.typeCast(value)}return value},setValue:function(instance,value){var data=this.data(instance);if(!data)return;if(this.typeCast){value=this.typeCast(value)}if(value!==null&&value!==undefined&&Ember.typeOf(value.toJSON)=="function"){value=value.toJSON()}Ember.Resource.deepSet(data,this.get("path"),value)},toJSON:function(instance){return getPath(instance,this.name)}});Ember.Resource.AttributeSchemaItem.reopenClass({create:function(name,schema){var definition=schema[name];var instance;if(this===Ember.Resource.AttributeSchemaItem){switch(definition.type){case Number:return Ember.Resource.NumberAttributeSchemaItem.create(name,schema);case String:return Ember.Resource.StringAttributeSchemaItem.create(name,schema);case Boolean:return Ember.Resource.BooleanAttributeSchemaItem.create(name,schema);case Date:return Ember.Resource.DateAttributeSchemaItem.create(name,schema);default:instance=this._super.apply(this,arguments);instance.set("path",definition.path||name);return instance}}else{instance=this._super.apply(this,arguments);instance.set("path",definition.path||name);return instance}}});Ember.Resource.NumberAttributeSchemaItem=Ember.Resource.AttributeSchemaItem.extend({theType:Number,typeCast:function(value){if(isNaN(value)){value=undefined}if(value===undefined||value===null||Ember.typeOf(value)==="number"){return value}else{return Number(value)}}});Ember.Resource.StringAttributeSchemaItem=Ember.Resource.AttributeSchemaItem.extend({theType:String,typeCast:function(value){if(value===undefined||value===null||isString(value)){return value}else{return""+value}}});Ember.Resource.BooleanAttributeSchemaItem=Ember.Resource.AttributeSchemaItem.extend({theType:Boolean,typeCast:function(value){if(value===undefined||value===null||Ember.typeOf(value)==="boolean"){return value}else{return value==="true"}}});Ember.Resource.DateAttributeSchemaItem=Ember.Resource.AttributeSchemaItem.extend({theType:Date,typeCast:function(value){if(!value||Ember.typeOf(value)==="date"){return value}else{return new Date(value)}},toJSON:function(instance){var value=getPath(instance,this.name);return value?value.toJSON():value}});Ember.Resource.HasOneSchemaItem=Ember.Resource.AbstractSchemaItem.extend({});Ember.Resource.HasOneSchemaItem.reopenClass({create:function(name,schema){var definition=schema[name];if(this===Ember.Resource.HasOneSchemaItem){if(definition.nested){return Ember.Resource.HasOneNestedSchemaItem.create(name,schema)}else{return Ember.Resource.HasOneRemoteSchemaItem.create(name,schema)}}else{var instance=this._super.apply(this,arguments);instance.set("theType",definition.type);if(definition.parse){instance.set("parse",definition.parse)}return instance}}});Ember.Resource.HasOneNestedSchemaItem=Ember.Resource.HasOneSchemaItem.extend({getValue:function(instance){var data=this.data(instance);if(!data)return;var type=this.get("type");var value=getPath(data,this.get("path"));if(value){value=(this.get("parse")||type.parse).call(type,Ember.copy(value));return type.create({},value)}return value},setValue:function(instance,value){var data=this.data(instance);if(!data)return;if(value instanceof this.get("type")){var valueId=getPath(value,"id");if(valueId){set(instance,this.get("name")+"_id",valueId)}else{Ember.Resource.deepSet(data,this.get("path"),getPath(value,"data"))}}else{Ember.Resource.deepSet(data,this.get("path"),value)}},toJSON:function(instance){var value=getPath(instance,this.name);return value?value.toJSON():value}});Ember.Resource.HasOneNestedSchemaItem.reopenClass({create:function(name,schema){var definition=schema[name];var instance=this._super.apply(this,arguments);instance.set("path",definition.path||name);var id_name=name+"_id";if(!schema[id_name]){schema[id_name]={type:Number,association:instance};schema[id_name]=Ember.Resource.HasOneNestedIdSchemaItem.create(id_name,schema)}return instance}});Ember.Resource.HasOneNestedIdSchemaItem=Ember.Resource.AbstractSchemaItem.extend({theType:Number,getValue:function(instance){return getPath(instance,this.get("path"))},setValue:function(instance,value){if(value==null){set(instance,getPath(this,"association.name"),null)}else{set(instance,getPath(this,"association.name"),{id:value})}}});Ember.Resource.HasOneNestedIdSchemaItem.reopenClass({create:function(name,schema){var definition=schema[name];var instance=this._super.apply(this,arguments);instance.set("association",definition.association);instance.set("path",definition.association.get("path")+".id");return instance}});Ember.Resource.HasOneRemoteSchemaItem=Ember.Resource.HasOneSchemaItem.extend({getValue:function(instance){var data=this.data(instance);if(!data)return;var id=getPath(data,this.get("path"));if(id){return this.get("type").create({},{id:id})}},setValue:function(instance,value){var data=this.data(instance);if(!data)return;var id=getPath(value||{},"id");Ember.Resource.deepSet(data,this.get("path"),id)}});Ember.Resource.HasOneRemoteSchemaItem.reopenClass({create:function(name,schema){var definition=schema[name];var instance=this._super.apply(this,arguments);var path=definition.path||name+"_id";instance.set("path",path);if(!schema[path]){schema[path]=Number;schema[path]=Ember.Resource.SchemaItem.create(path,schema)}return instance}});Ember.Resource.HasManySchemaItem=Ember.Resource.AbstractSchemaItem.extend({itemType:Ember.computed("theItemType",function(){var type=this.get("theItemType");if(isString(type)){type=Ember.Resource.lookUpType(type);if(type){this.set("theItemType",type)}else{type=this.get("theItemType")}}return type})});Ember.Resource.HasManySchemaItem.reopenClass({create:function(name,schema){var definition=schema[name];if(this===Ember.Resource.HasManySchemaItem){if(definition.url){return Ember.Resource.HasManyRemoteSchemaItem.create(name,schema)}else if(definition.nested){return Ember.Resource.HasManyNestedSchemaItem.create(name,schema)}else{return Ember.Resource.HasManyInArraySchemaItem.create(name,schema)}}else{var instance=this._super.apply(this,arguments);instance.set("theType",definition.type);instance.set("theItemType",definition.itemType);if(definition.parse){instance.set("parse",definition.parse)}return instance}}});Ember.Resource.HasManyRemoteSchemaItem=Ember.Resource.HasManySchemaItem.extend({dependencies:["id","isInitializing"],getValue:function(instance){if(getPath(instance,"isInitializing"))return;var options={type:this.get("itemType")};if(this.get("parse"))options.parse=this.get("parse");var url=this.url(instance);if(url){options.url=url}else{options.content=[]}return this.get("type").create(options)},setValue:function(instance,value){throw new Error("you can not set a remote has many association")}});Ember.Resource.HasManyRemoteSchemaItem.reopenClass({create:function(name,schema){var definition=schema[name];var instance=this._super.apply(this,arguments);if(Ember.typeOf(definition.url)==="function"){instance.url=definition.url}else{instance.url=function(obj){var id=obj.get("id");if(id){return definition.url.fmt(id)}}}return instance}});Ember.Resource.HasManyNestedSchemaItem=Ember.Resource.HasManySchemaItem.extend({getValue:function(instance){var data=this.data(instance);if(!data)return;data=getPath(data,this.get("path"));if(data===undefined||data===null)return data;data=Ember.copy(data);var options={type:this.get("itemType"),content:data};if(this.get("parse"))options.parse=this.get("parse");return this.get("type").create(options)},setValue:function(instance,value){},toJSON:function(instance){var value=getPath(instance,this.name);return value?value.toJSON():value}});Ember.Resource.HasManyNestedSchemaItem.reopenClass({create:function(name,schema){var definition=schema[name];var instance=this._super.apply(this,arguments);instance.set("path",definition.path||name);return instance}});Ember.Resource.HasManyInArraySchemaItem=Ember.Resource.HasManySchemaItem.extend({getValue:function(instance){var data=this.data(instance);if(!data)return;data=getPath(data,this.get("path"));if(data===undefined||data===null)return data;return this.get("type").create({type:this.get("itemType"),content:data.map(function(id){return{id:id}})})},setValue:function(instance,value){},toJSON:function(instance){var value=getPath(instance,this.name);return value?value.mapProperty("id"):value}});Ember.Resource.HasManyInArraySchemaItem.reopenClass({create:function(name,schema){var definition=schema[name];var instance=this._super.apply(this,arguments);instance.set("path",definition.path||name+"_ids");return instance}});Ember.Resource.Lifecycle={INITIALIZING:0,UNFETCHED:10,EXPIRING:20,EXPIRED:30,FETCHING:40,FETCHED:50,SAVING:60,DESTROYING:70,DESTROYED:80,clock:Ember.Object.create({now:new Date,tick:function(){Ember.Resource.Lifecycle.clock.set("now",new Date)},start:function(){this.stop();Ember.Resource.Lifecycle.clock.set("timer",setInterval(Ember.Resource.Lifecycle.clock.tick,1e4))},stop:function(){var timer=Ember.Resource.Lifecycle.clock.get("timer");if(timer){clearInterval(timer)}}}),classMixin:Ember.Mixin.create({create:function(options,data){options=options||{};options.resourceState=Ember.Resource.Lifecycle.INITIALIZING;var instance=this._super.apply(this,arguments);if(getPath(instance,"resourceState")===Ember.Resource.Lifecycle.INITIALIZING){set(instance,"resourceState",Ember.Resource.Lifecycle.UNFETCHED)}return instance},didEvictFromIdentityMap:function(entry){var fn=Em.Resource.identityMapEvictionHandler;fn&&fn.call(this,entry.value)}}),prototypeMixin:Ember.Mixin.create({expireIn:60*5,resourceState:0,init:function(){this._super.apply(this,arguments);var resourceStateBeforeSave;var updateExpiry=function(){var expireAt=new Date;expireAt.setSeconds(expireAt.getSeconds()+getPath(this,"expireIn"));set(this,"expireAt",expireAt)};this._listenerHandlers={willFetch:function(){set(this,"resourceState",Ember.Resource.Lifecycle.FETCHING);updateExpiry.call(this)},didFetch:function(){if(!getPath(this,"hasBeenFetched")){set(this,"hasBeenFetched",true)}set(this,"resourceState",Ember.Resource.Lifecycle.FETCHED);updateExpiry.call(this)},didFail:function(){set(this,"resourceState",Ember.Resource.Lifecycle.UNFETCHED);updateExpiry.call(this)},willSave:function(){resourceStateBeforeSave=getPath(this,"resourceState");set(this,"resourceState",Ember.Resource.Lifecycle.SAVING)},didSave:function(){set(this,"resourceState",resourceStateBeforeSave||Ember.Resource.Lifecycle.UNFETCHED)}};Ember.addListener(this,"willFetch",this,this._listenerHandlers.willFetch);Ember.addListener(this,"didFetch",this,this._listenerHandlers.didFetch);Ember.addListener(this,"didFail",this,this._listenerHandlers.didFail);Ember.addListener(this,"willSave",this,this._listenerHandlers.willSave);Ember.addListener(this,"didSave",this,this._listenerHandlers.didSave)},isFetchable:Ember.computed(function(key){var state=getPath(this,"resourceState");return state==Ember.Resource.Lifecycle.UNFETCHED||this.get("isExpired")}).volatile().readOnly(),isInitializing:Ember.computed("resourceState",function(key){return(getPath(this,"resourceState")||Ember.Resource.Lifecycle.INITIALIZING)===Ember.Resource.Lifecycle.INITIALIZING}).readOnly(),isFetching:Ember.computed("resourceState",function(key){return getPath(this,"resourceState")===Ember.Resource.Lifecycle.FETCHING}).readOnly(),isFetched:Ember.computed("resourceState",function(key){return getPath(this,"resourceState")===Ember.Resource.Lifecycle.FETCHED}).readOnly(),hasBeenFetched:false,isSavable:Ember.computed("resourceState",function(key){var state=getPath(this,"resourceState");var unsavableState=[Ember.Resource.Lifecycle.INITIALIZING,Ember.Resource.Lifecycle.FETCHING,Ember.Resource.Lifecycle.SAVING,Ember.Resource.Lifecycle.DESTROYING];return state&&!unsavableState.contains(state)}).readOnly(),isSaving:Ember.computed("resourceState",function(key){return getPath(this,"resourceState")===Ember.Resource.Lifecycle.SAVING}).readOnly(),resourceStateDidChange:function(){this.notifyPropertyChange("isFetchable")}.observes("resourceState"),expireAtDidChange:function(){this.notifyPropertyChange("isExpired");this.notifyPropertyChange("isFetchable")}.observes("expireAt"),expire:function(){Ember.run.next(this,function(){if(getPath(this,"isDestroyed")){return}this.expireNow()})},expireNow:function(){set(this,"expireAt",new Date)},refresh:function(){this.expireNow();return this.fetch()},isExpired:Ember.computed(function(name){var expireAt=this.get("expireAt");var now=new Date;return!!(expireAt&&expireAt.getTime()<=now.getTime())}).volatile().readOnly(),isFresh:function(data){return true},destroy:function(){var id=this.get("id");if(id&&this.constructor.identityMap){if(this===this.constructor.identityMap.get(id)){this.constructor.identityMap.remove(id)}}this._super()},willDestroy:function(){Ember.removeListener(this,"willFetch",this,this._listenerHandlers.willFetch);Ember.removeListener(this,"didFetch",this,this._listenerHandlers.didFetch);Ember.removeListener(this,"didFail",this,this._listenerHandlers.didFail);Ember.removeListener(this,"willSave",this,this._listenerHandlers.willSave);Ember.removeListener(this,"didSave",this,this._listenerHandlers.didSave)}})};Ember.Resource.reopen({isEmberResource:true,updateWithApiData:function(json){var data=getPath(this,"data"),parsedData;if(!data){return}parsedData=this.constructor.parse(json);if(!this.isFresh(parsedData)){return}Ember.beginPropertyChanges();try{Ember.Resource.deepMerge(data,parsedData)}catch(ex){Ember.Resource.logger&&typeof Ember.Resource.logger.error==="function"&&Ember.Resource.logger.error(ex)}finally{Ember.endPropertyChanges()}},willFetch:function(){},didFetch:function(){},willSave:function(){},didSave:function(){},didFail:function(){},fetched:function(){if(!this._fetchDfd){this._fetchDfd=$.Deferred()}return this._fetchDfd},fetch:function(ajaxOptions){var sideloads;if(this.deferredFetch&&!getPath(this,"isExpired")){return this.deferredFetch.promise()}if(!getPath(this,"isFetchable"))return $.when(this.get("data"),this);var url=this.resourceURL();if(!url)return $.when(this.get("data"),this);var self=this;self.willFetch.call(self);Ember.Resource.sendEvent(self,"willFetch");ajaxOptions=$.extend({},ajaxOptions,{url:url,resource:this,operation:"read"});sideloads=this.constructor.sideloads;if(sideloads&&sideloads.length!==0){ajaxOptions.data={include:sideloads.join(",")}}var result=this.deferredFetch=$.Deferred();Ember.Resource.fetch(this,ajaxOptions).done(function(json){if(self.get("isDestroying")||self.get("isDestroyed")){Ember.Resource.sendEvent(self,"didFail");result.reject();self.fetched().reject();return}self.updateWithApiData(json);self.didFetch.call(self);Ember.Resource.sendEvent(self,"didFetch");self.fetched().resolve(json,self);result.resolve(json,self)}).fail(function(){self.didFail.call(self);Ember.Resource.sendEvent(self,"didFail");var fetched=self.fetched();fetched.reject.apply(fetched,arguments);result.reject.apply(result,arguments)}).always(function(){self.deferredFetch=null});return result.promise()},resourceURL:function(){return this.constructor.resourceURL(this)},toJSON:function(options){var json={};var schemaItem,path,value;var schemaFields=Object.keys(this.constructor.schema);var fieldsToSet=options&&options.fields?options.fields.filter(function(schemaField){return schemaFields.indexOf(schemaField)!==-1}):schemaFields;fieldsToSet.forEach(function(name){schemaItem=this.constructor.schema[name];if(schemaItem instanceof Ember.Resource.AbstractSchemaItem){path=schemaItem.get("path");value=schemaItem.toJSON(this);if(value!==undefined){Ember.Resource.deepSet(json,path,value)}}},this);return json},isNew:Ember.computed("id",function(){return!getPath(this,"id")}),save:function(options){options=options||{};if(!getPath(this,"isSavable")){return $.Deferred().reject(false)}var ajaxOptions=$.extend({},options,{contentType:"application/json",data:JSON.stringify(this.toJSON(options)),resource:this});delete ajaxOptions.update;var isCreate=getPath(this,"isNew");if(isCreate){ajaxOptions.type="POST";ajaxOptions.url=this.constructor.resourceURL();ajaxOptions.operation="create"}else{ajaxOptions.type="PUT";ajaxOptions.url=this.resourceURL();ajaxOptions.operation="update"}var self=this;self.willSave.call(self);Ember.Resource.sendEvent(self,"willSave");var deferedSave=Ember.Resource.ajax(ajaxOptions);deferedSave.done(function(data,status,response){var location=response.getResponseHeader("Location");if(location){var id=self.constructor.idFromURL(location);if(id){set(self,"id",id)}}if(options.update!==false&&isObject(data)){self.updateWithApiData(data)}self.didSave.call(self,{created:isCreate,data:data});Ember.Resource.sendEvent(self,"didSave",[{created:isCreate,data:data}])}).fail(function(){self.didFail.call(self);Ember.Resource.sendEvent(self,"didFail")});return deferedSave},destroyResource:function(){var previousState=getPath(this,"resourceState"),self=this;set(this,"resourceState",Ember.Resource.Lifecycle.DESTROYING);return Ember.Resource.ajax({type:"DELETE",operation:"destroy",url:this.resourceURL(),resource:this}).done(function(){set(self,"resourceState",Ember.Resource.Lifecycle.DESTROYED);Em.run.next(function(){self.destroy()})}).fail(function(){set(self,"resourceState",previousState)})}},Ember.Resource.Lifecycle.prototypeMixin);expandSchema=function(schema){for(var name in schema){if(schema.hasOwnProperty(name)){schema[name]=Ember.Resource.SchemaItem.create(name,schema)}}return schema};mergeSchemas=function(childSchema,parentSchema){var schema=Ember.copy(parentSchema||{});for(var name in childSchema){if(childSchema.hasOwnProperty(name)){if(schema.hasOwnProperty(name)){throw new Error("Schema item '"+name+"' is already defined")}schema[name]=childSchema[name]}}return schema};createSchemaProperties=function(schema){var properties={},schemaItem;for(var propertyName in schema){if(schema.hasOwnProperty(propertyName)){properties[propertyName]=schema[propertyName].property()}}return properties};Ember.Resource.reopenClass({isEmberResource:true,schema:{},baseClass:function(){if(this===Ember.Resource){return null}else{return this.baseResourceClass||this}},subclassFor:function(options,data){return this},create:function(options,data){data=data||{};options=options||{};var klass=this.subclassFor(options,data),idToRestore=options.id;if(klass===this){var instance;var id=data.id||options.id;if(id&&!options.skipIdentityMap&&this.useIdentityMap){this.identityMap=this.identityMap||new Ember.Resource.IdentityMap(this.identityMapLimit,this.didEvictFromIdentityMap.bind(this));id=id.toString();instance=this.identityMap.get(id);if(!instance){instance=this._super({data:data});this.identityMap.put(id,instance)}else{instance.updateWithApiData(data);delete options.resourceState;delete options.id}}else{instance=this._super({data:data})}delete options.data;Ember.beginPropertyChanges();try{var mixin={};var hasMixin=false;for(var name in options){if(options.hasOwnProperty(name)){if(this.schema[name]){instance.set(name,options[name])}else{mixin[name]=options[name];hasMixin=true}}}if(hasMixin){instance.reopen(mixin)}}catch(ex){Ember.Resource.logger&&typeof Ember.Resource.logger.error==="function"&&Ember.Resource.logger.error(ex)}finally{Ember.endPropertyChanges()}options.id=idToRestore;return instance}else{return klass.create(options,data)}},parse:function(json){return json},define:function(options){options=options||{};var schema=expandSchema(options.schema);schema=mergeSchemas(schema,this.schema);var klass=this.extend(createSchemaProperties(schema),Ember.Resource.RemoteExpiry);var classOptions={schema:schema};if(this!==Ember.Resource){classOptions.baseResourceClass=this.baseClass()||this}if(options.url){classOptions.url=options.url}if(options.parse){classOptions.parse=options.parse}if(options.identityMapLimit){classOptions.identityMapLimit=options.identityMapLimit}if(typeof options.useIdentityMap!=="undefined"){classOptions.useIdentityMap=options.useIdentityMap}else{classOptions.useIdentityMap=true}if(options.sideloads){classOptions.sideloads=options.sideloads}klass.reopenClass(classOptions);return klass},extendSchema:function(schema){schema=expandSchema(schema);this.schema=mergeSchemas(schema,this.schema);this.reopen(createSchemaProperties(schema));return this},resourceURL:function(instance){if(Ember.typeOf(this.url)=="function"){return this.url(instance)}else if(this.url){if(instance){var id=getPath(instance,"id");if(id==null||id===""){return this.url}if(Ember.typeOf(id)!=="number"||id>0){return this.url+"/"+id}}else{return this.url}}},idFromURL:function(url){var regex;if(!this.schema.id)return;if(this.schema.id.get("type")===Number){regex=/\/(\d+)(\.\w+)?$/}else{regex=/\/([^\/\.]+)(\.\w+)?$/}var match=(url||"").match(regex);if(match){return match[1]}},findAndExpire:function(ids){var cache=this.identityMap&&this.identityMap.cache;var cacheRecord;if(ids==null||!cache)return;ids=Em.isArray(ids)?ids:[ids];for(var i=0;i", 17 | "repository": { 18 | "type": "git", 19 | "url": "git://github.com/zendesk/ember-resource.git" 20 | }, 21 | "main": "dist/ember-resource.js", 22 | "scripts": { 23 | "test": "make test" 24 | }, 25 | "devDependencies": { 26 | "broccoli": "0.7.2", 27 | "broccoli-cli": "^1.0.0", 28 | "broccoli-concat": "0.0.5", 29 | "broccoli-merge-trees": "0.1.3", 30 | "broccoli-uglify-js": "0.1.2", 31 | "chai": "^4.2.0", 32 | "jshint": "2.5.0", 33 | "mocha": "^7.1.0", 34 | "mocha-chrome": "^2.2.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /spec/javascripts/ajaxSpec.js: -------------------------------------------------------------------------------- 1 | describe('Ember.Resource.ajax', function() { 2 | 3 | beforeEach(function () { 4 | sinon.stub($, "ajax", function() { return $.when(); }); 5 | }); 6 | 7 | afterEach(function () { 8 | $.ajax.restore(); 9 | }); 10 | 11 | describe('when Ember.Resource.errorHandler is set', function() { 12 | 13 | beforeEach(function() { 14 | this.originalErrorHandler = Ember.Resource.errorHandler; 15 | Ember.Resource.errorHandler = Em.K; 16 | }); 17 | 18 | afterEach(function() { 19 | Ember.Resource.errorHandler = this.originalErrorHandler; 20 | }); 21 | 22 | it('passes an "error" option to $.ajax', function() { 23 | Ember.Resource.ajax({ url: '/not/found/1' }); 24 | expect($.ajax.called).to.be.ok; 25 | expect($.ajax.args[0][0].error).not.to.be.undefined; 26 | }); 27 | 28 | it('passes an "error" option to $.ajax even if passed a String', function() { 29 | Ember.Resource.ajax('/not/found/2'); 30 | expect($.ajax.called).to.be.ok; 31 | expect($.ajax.args[0][0].error).not.to.be.undefined; 32 | }); 33 | 34 | }); 35 | 36 | }); 37 | -------------------------------------------------------------------------------- /spec/javascripts/associationsSpec.js: -------------------------------------------------------------------------------- 1 | describe('associations', function() { 2 | var getPath = Ember.Resource.getPath; 3 | var newId = 100; 4 | 5 | var Address = Ember.Resource.define({ 6 | schema: { 7 | id: Number, 8 | street: String, 9 | zip: Number, 10 | city: String 11 | }, 12 | 13 | parse: function(json) { 14 | json.city = json.city || json.city_name; 15 | delete json.city_name; 16 | return json; 17 | } 18 | }); 19 | 20 | describe("has one remote", function() { 21 | var subject; 22 | 23 | beforeEach(function() { 24 | var Person = Ember.Resource.define({ 25 | schema: { 26 | name: String, 27 | address: {type: Address} 28 | } 29 | }); 30 | subject = Person.create({}, { "address_id": 1 }); 31 | sinon.spy(subject, 'updateWithApiData'); 32 | subject.get('address'); 33 | }); 34 | 35 | it("shouldn't call updateWithApiData when getting resource", function() { 36 | expect(subject.updateWithApiData.callCount).to.equal(0); 37 | }); 38 | }); 39 | 40 | describe('has one embedded', function() { 41 | var data; 42 | 43 | beforeEach(function() { 44 | data = { 45 | name: 'Joe Doe', 46 | address: { 47 | id: newId++, 48 | street: '1 My Street', 49 | zip: 12345 50 | } 51 | }; 52 | }); 53 | 54 | it('should use embedded data', function() { 55 | var Person = Ember.Resource.define({ 56 | schema: { 57 | name: String, 58 | address: {type: Address, nested: true} 59 | } 60 | }); 61 | 62 | var instance = Person.create({}, data); 63 | var address = instance.get('address'); 64 | 65 | expect(address instanceof Address).to.equal(true); 66 | expect(address.get('street')).to.equal('1 My Street'); 67 | expect(address.get('zip')).to.equal(12345); 68 | 69 | instance.set('address', Address.create({street: '2 Your Street'})); 70 | expect(getPath(instance, 'data.address.street')).to.equal('2 Your Street'); 71 | expect(getPath(instance, 'data.address.zip')).to.be.undefined; 72 | }); 73 | 74 | it('should support path overriding', function() { 75 | var Person = Ember.Resource.define({ 76 | schema: { 77 | name: String, 78 | address: {type: Address, nested: true, path: 'addresses.home'} 79 | } 80 | }); 81 | 82 | data.addresses = { home: data.address }; 83 | delete data.address; 84 | 85 | var instance = Person.create({}, data); 86 | var address = instance.get('address'); 87 | 88 | expect(address instanceof Address).to.equal(true); 89 | expect(address.get('street')).to.equal('1 My Street'); 90 | expect(address.get('zip')).to.equal(12345); 91 | 92 | instance.set('address', Address.create({street: '2 Your Street'})); 93 | expect(getPath(instance, 'data.addresses.home.street')).to.equal('2 Your Street'); 94 | expect(getPath(instance, 'data.addresses.home.zip')).to.be.undefined; 95 | }); 96 | 97 | it('should have an id accessor', function() { 98 | var Person = Ember.Resource.define({ 99 | schema: { 100 | name: String, 101 | address: {type: Address, nested: true} 102 | } 103 | }); 104 | 105 | data.address.id = '1'; 106 | 107 | var instance = Person.create({}, data); 108 | data = instance.get('data'); 109 | var address = instance.get('address'); 110 | 111 | expect(instance.get('address_id')).to.equal(1); 112 | 113 | instance.set('address_id', '2'); 114 | 115 | expect(instance.get('address_id')).to.equal(2); 116 | expect(instance.get('address')).not.to.equal(address); 117 | expect(getPath(instance, 'address.id')).to.equal(2); 118 | }); 119 | 120 | it("should not share internal data with other objects", function() { 121 | var Person = Ember.Resource.define({ 122 | schema: { 123 | id: Number, 124 | name: String, 125 | address: { type: Address, nested: true } 126 | } 127 | }); 128 | 129 | var person = Person.create({}, data); 130 | var newAddress = Address.create({ id: newId++, street: '2 Main Street' }); 131 | 132 | person.set('address', newAddress); 133 | expect(person.get('data').address).not.to.equal(newAddress.get('data')); 134 | }); 135 | 136 | it("should not copy internal data from other objects", function() { 137 | var Person = Ember.Resource.define({ 138 | schema: { 139 | id: Number, 140 | name: String, 141 | address: { type: Address, nested: true } 142 | } 143 | }); 144 | 145 | var person = Person.create({}, data); 146 | var newAddressId = newId++; 147 | var newAddress = Address.create({ id: newAddressId, street: '2 Main Street' }); 148 | 149 | person.set('address', newAddress); 150 | expect(person.get('data').address.id).to.equal(newAddressId); 151 | 152 | // update the local data: 153 | expect(person.get('address').get('street')).to.equal('2 Main Street'); 154 | 155 | // but don't modify what we got from the server: 156 | expect(person.get('data.address.street')).not.to.equal('2 Main Street'); 157 | }); 158 | 159 | describe('nullable behavior', function() { 160 | var Person, instance; 161 | beforeEach(function() { 162 | Person = Ember.Resource.define({ 163 | schema: { 164 | name: String, 165 | address: {type: Address, nested: true} 166 | } 167 | }); 168 | }); 169 | 170 | it('should nullify the id attribute when the association is nullified', function() { 171 | instance = Person.create({}, data); 172 | expect(instance.get('address_id')).not.to.equal(null); 173 | 174 | instance.set('address', null); 175 | expect(instance.get('address_id')).to.equal(null); 176 | 177 | }); 178 | 179 | it('should nullify the association when the id attribute is nullified', function() { 180 | instance = Person.create({}, data); 181 | expect(instance.get('address')).not.to.equal(null); 182 | 183 | instance.set('address_id', null); 184 | expect(instance.get('address')).to.equal(null); 185 | 186 | }); 187 | }); 188 | }); 189 | 190 | describe('has many', function() { 191 | var Person; 192 | 193 | describe('with url', function() { 194 | 195 | beforeEach(function() { 196 | Person = Ember.Resource.define({ 197 | schema: { 198 | id: Number, 199 | name: String, 200 | home_addresses: { 201 | type: Ember.ResourceCollection, 202 | itemType: Address, 203 | url: '/people/%@/addresses' 204 | }, 205 | work_addresses: { 206 | type: Ember.ResourceCollection, 207 | itemType: Address, 208 | url: function(instance) { 209 | return '/people/' + instance.get('id') + '/addresses'; 210 | } 211 | } 212 | } 213 | }); 214 | }); 215 | 216 | it('should support url strings', function() { 217 | var person = Person.create({id: 1, name: 'Mick Staugaard'}); 218 | var homeAddresses = person.get('home_addresses'); 219 | 220 | expect(homeAddresses).to.not.equal(undefined); 221 | expect(homeAddresses instanceof Ember.ResourceCollection).to.equal(true); 222 | expect(homeAddresses.type).to.equal(Address); 223 | expect(homeAddresses.url).to.equal('/people/1/addresses'); 224 | }); 225 | 226 | it('should support url functions', function() { 227 | var person = Person.create({id: 1, name: 'Mick Staugaard'}); 228 | var workAddresses = person.get('work_addresses'); 229 | 230 | expect(workAddresses).to.not.equal(undefined); 231 | expect(workAddresses instanceof Ember.ResourceCollection).to.equal(true); 232 | expect(workAddresses.type).to.equal(Address); 233 | expect(workAddresses.url).to.equal('/people/1/addresses'); 234 | }); 235 | }); 236 | 237 | describe('nested', function() { 238 | beforeEach(function() { 239 | Person = Ember.Resource.define({ 240 | schema: { 241 | name: String, 242 | home_addresses: { 243 | type: Ember.ResourceCollection, 244 | itemType: Address, 245 | nested: true 246 | }, 247 | work_addresses: { 248 | type: Ember.ResourceCollection, 249 | itemType: Address, 250 | nested: true, 251 | path: 'office_addresses' 252 | } 253 | } 254 | }); 255 | }); 256 | 257 | it('should use the nested data', function() { 258 | var data = { 259 | name: 'Joe Doe', 260 | home_addresses: [ 261 | { 262 | street: '1 My Street', 263 | zip: 12345 264 | }, 265 | { 266 | street: '2 Your Street', 267 | zip: 23456 268 | } 269 | ] 270 | }; 271 | 272 | var person = Person.create({}, data); 273 | var homeAddresses = person.get('home_addresses'); 274 | 275 | expect(homeAddresses).to.not.equal(undefined); 276 | expect(homeAddresses instanceof Ember.ResourceCollection).to.equal(true); 277 | expect(homeAddresses.type).to.equal(Address); 278 | expect(homeAddresses.get('length')).to.equal(2); 279 | 280 | var address; 281 | for (var i=0; i < data.home_addresses.length; i++) { 282 | address = homeAddresses.objectAt(i); 283 | expect(address).to.not.equal(undefined); 284 | expect(address instanceof Address).to.equal(true); 285 | expect(address.get('street')).to.equal(data.home_addresses[i].street); 286 | expect(address.get('zip')).to.equal(data.home_addresses[i].zip); 287 | } 288 | 289 | address = homeAddresses.objectAt(0); 290 | 291 | address.set('street', '3 Other Street'); 292 | expect(address.get('street')).to.equal('3 Other Street'); 293 | }); 294 | 295 | it("should use the class's parse method", function() { 296 | var data = { 297 | name: 'Joe Doe', 298 | home_addresses: [ 299 | { 300 | street: '1 My Street', 301 | zip: 12345, 302 | city_name: 'Anytown' 303 | } 304 | ] 305 | }; 306 | 307 | var person = Person.create({}, data), 308 | address = person.get('home_addresses').objectAt(0); 309 | 310 | expect(address).to.be.ok; 311 | expect(address.get('city')).to.equal('Anytown'); 312 | }); 313 | }); 314 | 315 | describe('in array', function() { 316 | beforeEach(function() { 317 | Person = Ember.Resource.define({ 318 | schema: { 319 | name: String, 320 | home_addresses: { 321 | type: Ember.ResourceCollection, 322 | itemType: Address, 323 | path: 'home_address_ids' 324 | } 325 | } 326 | }); 327 | }); 328 | 329 | it("should use the ids in the array", function() { 330 | var data = { 331 | name: 'Joe Doe', 332 | home_address_ids: [1, 2] 333 | }; 334 | 335 | var person = Person.create({}, data), 336 | addresses = person.get('home_addresses'); 337 | 338 | expect(addresses.get('length')).to.equal(2); 339 | expect(addresses.objectAt(0).get('id')).to.equal(1); 340 | expect(addresses.objectAt(1).get('id')).to.equal(2); 341 | }); 342 | }); 343 | 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /spec/javascripts/deepMergeSpec.js: -------------------------------------------------------------------------------- 1 | describe('deepMerge', function() { 2 | it('should add missing keys', function() { 3 | var obj = {}; 4 | Ember.Resource.deepMerge(obj, {a: 'foo'}); 5 | 6 | expect(obj.a).to.not.equal(undefined); 7 | expect(obj.a).to.equal('foo'); 8 | }); 9 | 10 | it('should override keys', function() { 11 | var obj = {a: 'foo'}; 12 | Ember.Resource.deepMerge(obj, {a: 'bar'}); 13 | 14 | expect(obj.a).to.not.equal(undefined); 15 | expect(obj.a).to.equal('bar'); 16 | }); 17 | 18 | it('should leave other keys', function() { 19 | var obj = {a: 'foo'}; 20 | Ember.Resource.deepMerge(obj, {b: 'bar'}); 21 | 22 | expect(obj.a).to.not.equal(undefined); 23 | expect(obj.a).to.equal('foo'); 24 | }); 25 | 26 | it('merge recursively', function() { 27 | var obj = {a: {b: 'foo'}}; 28 | Ember.Resource.deepMerge(obj, {a: {b: 'bar'}}); 29 | 30 | expect(obj.a.b).to.not.equal(undefined); 31 | expect(obj.a.b).to.equal('bar'); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /spec/javascripts/deepSetSpec.js: -------------------------------------------------------------------------------- 1 | describe('deepSet', function() { 2 | it('should set at the given path', function() { 3 | var obj = {}; 4 | Ember.Resource.deepSet(obj, 'a', 'foo'); 5 | 6 | expect(obj.a).to.not.equal(undefined); 7 | expect(obj.a).to.equal('foo'); 8 | }); 9 | 10 | it('should overwrite at the given path', function() { 11 | var obj = {a: 'foo'}; 12 | Ember.Resource.deepSet(obj, 'a', 'bar'); 13 | 14 | expect(obj.a).to.not.equal(undefined); 15 | expect(obj.a).to.equal('bar'); 16 | }); 17 | 18 | it('should create empty Object at missing nodes', function() { 19 | var obj = {}; 20 | Ember.Resource.deepSet(obj, 'a.b.c', 'foo'); 21 | 22 | expect(obj.a).to.not.equal(undefined); 23 | expect(Ember.typeOf(obj.a)).to.equal('object'); 24 | 25 | expect(obj.a.b).to.not.equal(undefined); 26 | expect(Ember.typeOf(obj.a.b)).to.equal('object'); 27 | 28 | expect(obj.a.b.c).to.not.equal(undefined); 29 | expect(obj.a.b.c).to.equal('foo'); 30 | }); 31 | 32 | it("should not pass a reference to another objects data", function() { 33 | var ticket = { 34 | data: { 35 | group: { 36 | id: 1, 37 | name: 'Support', 38 | users: { 39 | id: 3, 40 | name: 'User 1' 41 | } 42 | } 43 | } 44 | }; 45 | 46 | var group = { 47 | data: { 48 | id: 2, 49 | name: 'Development', 50 | users: { 51 | id: 4, 52 | name: 'User 2' 53 | } 54 | } 55 | }; 56 | 57 | Ember.Resource.deepSet(ticket, 'data.group', group.data); 58 | 59 | expect(ticket.data.group.id).to.equal(group.data.id); 60 | expect(ticket.data.group.name).to.equal(group.data.name); 61 | 62 | expect(ticket.data.group).not.to.equal(group.data); 63 | expect(ticket.data.group.users).not.to.equal(group.data.users); 64 | }); 65 | 66 | }); 67 | -------------------------------------------------------------------------------- /spec/javascripts/destroySpec.js: -------------------------------------------------------------------------------- 1 | describe('Destroying resources', function() { 2 | var Model, model, server; 3 | 4 | beforeEach(function() { 5 | Model = Ember.Resource.define({ 6 | schema: { 7 | id: Number, 8 | name: String, 9 | subject: String 10 | }, 11 | url: '/people' 12 | }); 13 | 14 | server = sinon.fakeServer.create(); 15 | }); 16 | 17 | afterEach(function() { 18 | server.restore(); 19 | Ember.Resource.errorHandler = null; 20 | }); 21 | 22 | describe('#destroy', function() { 23 | 24 | beforeEach(function() { 25 | model = Model.create({id: 1}); 26 | expect(Model.identityMap.get(1)).to.equal(model); 27 | }); 28 | 29 | it('should remove the object from the identity map', function() { 30 | model.destroy(); 31 | expect(Model.identityMap.get(1)).to.be.undefined; 32 | }); 33 | 34 | it('should not remove the object from the identity map when the instance if different', function() { 35 | var otherModel = Model.create({id: 1, skipIdentityMap: true}); 36 | 37 | expect(otherModel).to.not.equal(model); 38 | otherModel.destroy(); 39 | expect(Model.identityMap.get(1)).to.equal(model); 40 | model.destroy(); 41 | expect(Model.identityMap.get(1)).to.be.undefined; 42 | }); 43 | }); 44 | 45 | describe('#destroy without an identityMap on the Model', function() { 46 | 47 | beforeEach(function() { 48 | model = Model.create(); 49 | model.set('id', 1); 50 | }); 51 | 52 | it('should not throw an exception', function() { 53 | expect(model.destroy.bind(model)).to.not.throw(Error); 54 | }); 55 | }); 56 | 57 | describe('destroyResource', function() { 58 | var resource; 59 | 60 | describe('instance destruction', function() { 61 | beforeEach(function() { 62 | server.respondWith('DELETE', '/people/1', [200, {}, '[["foo", "bar"]]']); 63 | resource = Model.create({ id: 1, name: 'f0o' }); 64 | resource.destroyResource(); 65 | server.respond(); 66 | }); 67 | 68 | it('should defer destroying the Em.Resource instance till the next run loop', function(done) { 69 | expect(resource.get('isDestroyed')).to.not.be.ok; 70 | 71 | // The destroy happens in the "next" run loop. 72 | Em.run.next(function() { 73 | 74 | // In the loop *after* that one, we check the resource state. 75 | Em.run.next(function() { 76 | expect(resource.get('isDestroyed')).to.be.ok; 77 | done(); 78 | }); 79 | }); 80 | }); 81 | }); 82 | 83 | describe('handling errors', function() { 84 | beforeEach(function() { 85 | server.respondWith('DELETE', '/people/1', [422, {}, '[["foo", "bar"]]']); 86 | }); 87 | 88 | it('should pass a reference to the resource to the error handling function', function() { 89 | var spy = sinon.spy(); 90 | 91 | Ember.Resource.errorHandler = function(a, b, c, fourthArgument) { 92 | spy(fourthArgument.resource, fourthArgument.operation); 93 | }; 94 | 95 | resource = Model.create({ id: 1, name: 'f0o' }); 96 | resource.destroyResource(); 97 | server.respond(); 98 | 99 | expect(spy.calledWith(resource, "destroy")).to.be.ok; 100 | }); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /spec/javascripts/fetchSpec.js: -------------------------------------------------------------------------------- 1 | describe('deferred fetch', function() { 2 | var Person, people, server, person, 3 | PERSON_DATA = { "id": 1, "name": "Mick Staugaard" }, 4 | PEOPLE_DATA = {"people" : [ PERSON_DATA ]}; 5 | 6 | beforeEach(function() { 7 | Person = Ember.Resource.define({ 8 | url: '/people', 9 | schema: { 10 | id: Number, 11 | name: String 12 | } 13 | }); 14 | 15 | server = sinon.fakeServer.create(); 16 | 17 | }); 18 | 19 | afterEach(function() { 20 | server.restore(); 21 | }); 22 | 23 | describe("fetched() for resources", function() { 24 | beforeEach(function() { 25 | server.respondWith("GET", "/people/1", 26 | [200, { "Content-Type": "application/json" }, 27 | JSON.stringify(PERSON_DATA) ]); 28 | }); 29 | 30 | it("should resolve with the resource when the fetch completes", function() { 31 | var handler = sinon.spy(); 32 | 33 | person = Person.create({id: 1}); 34 | person.fetched().done(handler); 35 | 36 | person.fetch(); 37 | server.respond(); 38 | 39 | expect(handler.calledWith(PERSON_DATA, person)).to.be.ok; 40 | }); 41 | }); 42 | 43 | describe('fetch() for resources', function() { 44 | beforeEach(function() { 45 | person = Person.create({id: 1}); 46 | server.respondWith("GET", "/people/1", 47 | [200, { "Content-Type": "application/json" }, 48 | JSON.stringify(PERSON_DATA) ]); 49 | 50 | }); 51 | 52 | describe('when unfetched', function() { 53 | it('resolves with the resource when the server responds', function() { 54 | var handler = sinon.spy(); 55 | 56 | person.fetch().done(handler); 57 | server.respond(); 58 | 59 | expect(handler.calledWith(PERSON_DATA, person)).to.be.ok; 60 | }); 61 | }); 62 | 63 | describe('when being fetched', function() { 64 | it('resolves with the resource when the server responds', function() { 65 | var handler = sinon.spy(), 66 | promise1, promise2; 67 | 68 | promise1 = person.fetch(); 69 | expect(person.get('isFetching')).to.be.ok; 70 | 71 | promise2 = person.fetch(); 72 | promise2.done(handler); 73 | 74 | expect(promise1).to.equal(promise2); 75 | 76 | expect(handler.callCount).to.equal(0); 77 | 78 | server.respond(); 79 | expect(handler.calledWith(PERSON_DATA, person)).to.be.ok; 80 | 81 | }); 82 | }); 83 | 84 | describe('when fetched, but not expired', function() { 85 | it('should resolve with the resource immediately', function() { 86 | var handler = sinon.spy(); 87 | 88 | person.fetch(); 89 | server.respond(); 90 | 91 | person.fetch().done(handler); 92 | expect(handler.calledWith(PERSON_DATA, person)).to.be.ok; 93 | }); 94 | }); 95 | 96 | describe('when the resource is destroyed before the response', function() { 97 | it('should call the fail handler', function() { 98 | var spy = sinon.spy(); 99 | 100 | person.fetch().fail(function() { 101 | spy(); 102 | }); 103 | person.destroy(); 104 | server.respond(); 105 | 106 | expect(spy.called).to.equal(true); 107 | }); 108 | }); 109 | 110 | describe('for resources with no resourceURL', function() { 111 | it("returns a resolved promise", function() { 112 | var handler = sinon.spy(); 113 | person.resourceURL = function() { return undefined; }; 114 | 115 | person.fetch().done(handler); 116 | expect(handler.callCount).to.equal(1); 117 | expect(handler.getCall(0).args[0].id).to.equal(person.get('id')); 118 | expect(handler.getCall(0).args[1]).to.equal(person); 119 | }); 120 | }); 121 | 122 | describe('when there are errors', function() { 123 | beforeEach(function() { 124 | server.respondWith('GET', '/people/2', [422, {}, '[["foo", "bar"]]']); 125 | }); 126 | 127 | it('should not prevent subsequent fetches from happening', function() { 128 | var resource = Person.create({ id: 2 }); 129 | 130 | resource.fetch(); 131 | server.respond(); 132 | 133 | sinon.stub(resource, 'willFetch'); 134 | resource.fetch(); 135 | server.respond(); 136 | expect(resource.willFetch.callCount).to.equal(1); 137 | }); 138 | 139 | it('should pass a reference to the resource to the error handling function', function() { 140 | var spy = sinon.spy(); 141 | Ember.Resource.errorHandler = function(a, b, c, fourthArgument) { 142 | spy(fourthArgument.resource, fourthArgument.operation); 143 | }; 144 | 145 | var resource = Person.create({ id: 2 }); 146 | 147 | resource.fetch(); 148 | server.respond(); 149 | 150 | expect(spy.calledWith(resource, "read")).to.be.ok; 151 | }); 152 | }); 153 | 154 | }); 155 | 156 | 157 | describe("fetch() for resource collections", function() { 158 | beforeEach(function() { 159 | people = Ember.ResourceCollection.create({ 160 | type: Person, 161 | parse: function(json) { return json.people; } 162 | }); 163 | }); 164 | 165 | describe('when being fetched', function() { 166 | var handler, promise1, promise2; 167 | 168 | beforeEach(function() { 169 | handler = sinon.spy(); 170 | server.respondWith('GET', '/people', [200, {}, JSON.stringify(PEOPLE_DATA)]); 171 | promise1 = people.fetch(); 172 | promise2 = people.fetch(); 173 | promise2.done(handler); 174 | }); 175 | 176 | it('returns the same promise for successive fetches', function() { 177 | expect(promise1).to.equal(promise2); 178 | }); 179 | 180 | it('resolves with the collection when the server responds', function() { 181 | expect(handler.callCount).to.equal(0); 182 | server.respond(); 183 | expect(handler.calledWith(PEOPLE_DATA, people)).to.be.ok; 184 | }); 185 | 186 | }); 187 | 188 | describe('when the resource is destroyed before the response', function() { 189 | it('should call the fail handler', function() { 190 | var spy = sinon.spy(); 191 | 192 | person.fetch().fail(function() { 193 | spy(); 194 | }); 195 | person.destroy(); 196 | server.respond(); 197 | 198 | expect(spy.called).to.equal(true); 199 | }); 200 | }); 201 | 202 | describe('when there are errors', function() { 203 | beforeEach(function() { 204 | server.respondWith('GET', '/people', [422, {}, '[["foo", "bar"]]']); 205 | }); 206 | 207 | it('should pass a reference to the resource to the error handling function', function() { 208 | var spy = sinon.spy(); 209 | Ember.Resource.errorHandler = function(a, b, c, fourthArgument) { 210 | spy(fourthArgument.resource, fourthArgument.operation); 211 | }; 212 | 213 | people.fetch(); 214 | server.respond(); 215 | 216 | expect(spy.calledWith(people, "read")).to.be.ok; 217 | }); 218 | 219 | it("collection should still be fetchable", function() { 220 | people.fetch(); 221 | server.respond(); 222 | expect(people.get('isFetchable')).to.be.true; 223 | }); 224 | }); 225 | 226 | }); 227 | 228 | describe("fetched() for resource collections", function() { 229 | beforeEach(function() { 230 | server.respondWith("GET", "/people", 231 | [200, { "Content-Type": "application/json" }, 232 | JSON.stringify([ PERSON_DATA ]) ]); 233 | people = Ember.ResourceCollection.create({type: Person}); 234 | 235 | }); 236 | 237 | it("should resolve with the collection when the fetch completes", function(done) { 238 | var handler = sinon.spy(); 239 | 240 | people.expire(); 241 | 242 | people.fetched().done(handler); 243 | 244 | people.fetch(); 245 | server.respond(); 246 | 247 | setTimeout(function() { 248 | expect(handler.calledWith([PERSON_DATA], people)).to.be.ok; 249 | done(); 250 | }, 1000); 251 | }); 252 | }); 253 | 254 | describe('#ajax', function() { 255 | var abort, done, request; 256 | 257 | beforeEach(function() { 258 | server.respondWith('GET', '/autocomplete', [200, { "Content-Type": "application/json" }, '[]']); 259 | }); 260 | 261 | describe('using the abortCallback option', function() { 262 | beforeEach(function() { 263 | abort = sinon.spy(); 264 | done = sinon.spy(); 265 | }); 266 | 267 | it('should call the abortCallback option with an ajax abort function', function() { 268 | request = Em.Resource.ajax({ 269 | url: '/autocomplete', 270 | abortCallback: abort 271 | }); 272 | 273 | expect(abort.called).to.be.true; 274 | }); 275 | 276 | it('should call the done callback when not aborted', function() { 277 | request = Em.Resource.ajax({ 278 | url: '/autocomplete', 279 | abortCallback: abort 280 | }); 281 | 282 | request.done(done); 283 | server.respond(); 284 | expect(done.called).to.be.true; 285 | }); 286 | 287 | it('should not call the done callback when abort is called', function() { 288 | request = Em.Resource.ajax({ 289 | url: '/autocomplete', 290 | abortCallback: function(abort) { 291 | abort(); 292 | } 293 | }); 294 | 295 | request.done(done); 296 | server.respond(); 297 | expect(done.called).to.be.false; 298 | }); 299 | }); 300 | }); 301 | }); 302 | -------------------------------------------------------------------------------- /spec/javascripts/findAndExpireSpec.js: -------------------------------------------------------------------------------- 1 | describe('findAndExpire', function() { 2 | 3 | var Class = Em.Resource.define({ 4 | schema: { 5 | id: Number 6 | } 7 | }); 8 | 9 | it('finds and expires instances by id', function() { 10 | var instance1 = Class.create({id: 1}); 11 | var instance2 = Class.create({id: 2}); 12 | 13 | expect(instance1.get('isExpired')).to.equal(false); 14 | expect(instance2.get('isExpired')).to.equal(false); 15 | 16 | Class.findAndExpire([1, 2, 3, 4]); 17 | 18 | expect(instance1.get('isExpired')).to.equal(true); 19 | expect(instance2.get('isExpired')).to.equal(true); 20 | expect(Class.identityMap.cache.keys().join()).to.equal("1,2"); 21 | }); 22 | 23 | it('finds and expires an instance by id', function() { 24 | var instance10 = Class.create({id: 10}); 25 | 26 | expect(instance10.get('isExpired')).to.equal(false); 27 | 28 | Class.findAndExpire(10); 29 | 30 | expect(instance10.get('isExpired')).to.equal(true); 31 | }); 32 | 33 | }); 34 | -------------------------------------------------------------------------------- /spec/javascripts/identityMapSpec.js: -------------------------------------------------------------------------------- 1 | describe('identity map', function() { 2 | var Address = Em.Resource.define({ 3 | identityMapLimit: 10 4 | }); 5 | 6 | describe('that wants to skip the identity map', function() { 7 | var Model; 8 | beforeEach(function() { 9 | Model = Em.Resource.define({ 10 | useIdentityMap: false 11 | }); 12 | }); 13 | 14 | it('should not have an identity map', function() { 15 | expect(Model.identityMap).to.be.undefined; 16 | }); 17 | 18 | it('should not get an identity map when you create an instance', function() { 19 | Model.create({id: 1}); 20 | expect(Model.identityMap).to.be.undefined; 21 | }); 22 | 23 | }); 24 | 25 | it('should default to a limit of DEFAULT_IDENTITY_MAP_LIMIT', function() { 26 | var Foo = Em.Resource.define(); 27 | Foo.create({id: 1}); 28 | expect(Foo.identityMap.limit()).to.equal(Ember.Resource.IdentityMap.DEFAULT_IDENTITY_MAP_LIMIT); 29 | }); 30 | 31 | it('should return the same object when requested multiple times', function() { 32 | var address1 = Address.create({id: 1}); 33 | var address2 = Address.create({id: 1}); 34 | expect(address1).to.equal(address2); 35 | }); 36 | 37 | it('should limit the number of objects retained', function() { 38 | var address; 39 | for(var i=1; i<=20; i++) { 40 | address = Address.create({id: i}); 41 | } 42 | 43 | expect(Address.identityMap.size()).to.equal(10); 44 | }); 45 | 46 | it('should not clobber the resourceState of an already cached object', function() { 47 | var address = Address.create({id: 1}); 48 | address.set('resourceState', 50); 49 | address = Address.create({id: 1}); 50 | expect(address.get('resourceState')).to.equal(50); 51 | }); 52 | 53 | describe("for resource collections", function() { 54 | var Addresses = Em.ResourceCollection.extend(); 55 | 56 | Addresses.reopenClass({ 57 | identityMapLimit: 10 58 | }); 59 | 60 | it('should default to a limit of 5x DEFAULT_IDENTITY_MAP_LIMIT', function() { 61 | var Foo = Em.ResourceCollection.extend(); 62 | Foo.create({type: Address, url: '/foo'}); 63 | expect(Foo.identityMap.limit()).to.equal(Ember.Resource.IdentityMap.DEFAULT_IDENTITY_MAP_LIMIT * 5); 64 | }); 65 | 66 | it('should return the same object when requested multiple times', function() { 67 | var addresses1 = Addresses.create({type: Address, url: '/address/'}); 68 | var addresses2 = Addresses.create({type: Address, url: '/address/'}); 69 | 70 | expect(addresses1).to.equal(addresses2); 71 | }); 72 | 73 | it('should limit the number of objects retained', function() { 74 | var address; 75 | for(var i=1; i<=20; i++) { 76 | address = Addresses.create({type: Address, url: '/address/' + i}); 77 | } 78 | 79 | expect(Address.identityMap.size()).to.equal(10); 80 | }); 81 | }); 82 | 83 | describe("Who wish to opt out of the identity map", function() { 84 | var Collection; 85 | 86 | beforeEach(function() { 87 | Collection = Em.ResourceCollection.extend().reopenClass({ 88 | useIdentityMap: false 89 | }); 90 | }); 91 | 92 | it("should return different collections when requested multiple times", function() { 93 | var addresses1 = Collection.create({ type: Address, url: '/addresses' }); 94 | var addresses2 = Collection.create({ type: Address, url: '/addresses' }); 95 | 96 | expect(addresses1).not.to.equal(addresses2); 97 | }); 98 | }); 99 | 100 | describe('Given an object in the identity map', function() { 101 | var model, spy; 102 | 103 | beforeEach(function() { 104 | Address.identityMap.clear(); 105 | model = Address.create({ id: 1 }); 106 | spy = sinon.spy(model, 'updateWithApiData'); 107 | }); 108 | 109 | afterEach(function() { 110 | Address.identityMap.clear(); 111 | }); 112 | 113 | describe('Updating that instance from API data', function() { 114 | beforeEach(function() { 115 | Address.create({ id: 1 }, { foo: 'bar' }); 116 | }); 117 | 118 | it('should call updateWithApiData', function() { 119 | expect(spy.callCount).to.equal(1); 120 | }); 121 | }); 122 | }); 123 | 124 | }); 125 | -------------------------------------------------------------------------------- /spec/javascripts/inheritanceSpec.js: -------------------------------------------------------------------------------- 1 | describe('Inheritance', function() { 2 | var Person; 3 | 4 | var keys = function(object) { 5 | var keys = []; 6 | for (var key in object) { 7 | if (object.hasOwnProperty(key)) keys.push(key); 8 | } 9 | return keys.sort(); 10 | }; 11 | 12 | beforeEach(function() { 13 | Person = Ember.Resource.define({ 14 | url: '/people', 15 | schema: { 16 | id: Number, 17 | name: String 18 | } 19 | }); 20 | }); 21 | 22 | it('should support many levels of inheritance', function() { 23 | expect(keys(Person.schema)).to.deep.equal(['id', 'name']); 24 | 25 | var Worker = Person.define({ 26 | schema: { 27 | salary: Number 28 | } 29 | }); 30 | 31 | expect(keys(Worker.schema)).to.deep.equal(['id', 'name', 'salary']); 32 | 33 | var LuckyBastard = Worker.define({ 34 | schema: { 35 | stockOptions: Number 36 | } 37 | }); 38 | 39 | expect(keys(LuckyBastard.schema)).to.deep.equal(['id', 'name', 'salary', 'stockOptions']); 40 | }); 41 | 42 | it('should blow up when you try to redifine properties', function() { 43 | var defineBadSubclass = function() { 44 | return Person.define({ 45 | schema: { 46 | name: String 47 | } 48 | }); 49 | }; 50 | expect(defineBadSubclass).to.throw("Schema item 'name' is already defined"); 51 | }); 52 | 53 | it('should inherit the resource url', function() { 54 | var personUrl = Person.url; 55 | 56 | expect(personUrl).to.not.equal(undefined); 57 | 58 | var Worker = Person.define({ 59 | schema: { 60 | salary: Number 61 | } 62 | }); 63 | 64 | expect(Worker.url).to.equal(personUrl); 65 | }); 66 | 67 | it('should allow overriding the url', function() { 68 | var personUrl = Person.url; 69 | 70 | expect(personUrl).to.not.equal(undefined); 71 | expect(personUrl).to.not.equal('/workers'); 72 | 73 | var Worker = Person.define({ 74 | url: '/workers', 75 | schema: { 76 | salary: Number 77 | } 78 | }); 79 | 80 | expect(Worker.url).to.equal('/workers'); 81 | 82 | }); 83 | 84 | }); 85 | -------------------------------------------------------------------------------- /spec/javascripts/lifecycleSpec.js: -------------------------------------------------------------------------------- 1 | /*globals Ember */ 2 | describe('Lifecycle', function() { 3 | var Person, server; 4 | 5 | beforeEach(function() { 6 | Person = Ember.Resource.define({ 7 | url: '/people', 8 | schema: { 9 | id: Number, 10 | name: String 11 | } 12 | }); 13 | 14 | server = sinon.fakeServer.create(); 15 | server.respondWith("GET", "/people/1", 16 | [200, { "Content-Type": "application/json" }, 17 | '{ "id": 1, "name": "Mick Staugaard" }']); 18 | }); 19 | 20 | afterEach(function() { 21 | server.restore(); 22 | }); 23 | 24 | describe('new object', function() { 25 | var person; 26 | beforeEach(function() { 27 | person = Person.create({id: 1}); 28 | }); 29 | 30 | it('should be in the UNFETCHED state', function() { 31 | expect(person.get('resourceState')).to.equal(Ember.Resource.Lifecycle.UNFETCHED); 32 | }); 33 | 34 | it('should not be marked as having been fetched', function() { 35 | expect(person.get('hasBeenFetched')).to.be.false; 36 | }); 37 | 38 | it('should not be expired', function() { 39 | expect(person.get('isExpired')).to.not.be.ok; 40 | }); 41 | 42 | it('should never expire', function() { 43 | expect(person.get('expireAt')).to.be.undefined; 44 | }); 45 | 46 | it('should be fetchable', function() { 47 | expect(person.get('isFetchable')).to.equal(true); 48 | }); 49 | }); 50 | 51 | describe('fetching', function() { 52 | var person; 53 | beforeEach(function() { 54 | person = Person.create({id: 1}); 55 | person.fetch(); 56 | }); 57 | 58 | it('should put the object in a FETCHING state', function() { 59 | expect(person.get('resourceState')).to.equal(Ember.Resource.Lifecycle.FETCHING); 60 | }); 61 | 62 | describe('is done', function() { 63 | beforeEach(function() { 64 | server.respond(); 65 | }); 66 | 67 | it('should put the object in a FETCHED state when the fetch is done', function() { 68 | expect(person.get('resourceState')).to.equal(Ember.Resource.Lifecycle.FETCHED); 69 | }); 70 | 71 | it('should mark the object as having been fetched', function() { 72 | expect(person.get('hasBeenFetched')).to.be.true; 73 | }); 74 | 75 | it('should set expiry in 5 minutes', function() { 76 | var fiveMinutesFromNow = new Date(); 77 | fiveMinutesFromNow.setSeconds(fiveMinutesFromNow.getSeconds() + (60 * 5)); 78 | 79 | expect(person.get('expireAt')).to.not.equal(undefined); 80 | expect(person.get('expireAt').getTime()).to.be.within(fiveMinutesFromNow.getTime() - 100, fiveMinutesFromNow.getTime() + 100); 81 | }); 82 | }); 83 | 84 | }); 85 | 86 | describe('expiry', function() { 87 | var person; 88 | 89 | beforeEach(function() { 90 | person = Person.create({id: 1}); 91 | }); 92 | 93 | it('should be expired with an expireAt in the past', function() { 94 | var expiry = new Date(); 95 | expiry.setFullYear(expiry.getFullYear() - 1); 96 | person.set('expireAt', expiry); 97 | expect(person.get('isExpired')).to.equal(true); 98 | }); 99 | 100 | it('should be expired with an expireAt in the future', function() { 101 | var expiry = new Date(); 102 | expiry.setFullYear(expiry.getFullYear() + 1); 103 | person.set('expireAt', expiry); 104 | expect(person.get('isExpired')).to.not.be.ok; 105 | expect(person.get('resourceState')).to.equal(Ember.Resource.Lifecycle.UNFETCHED); 106 | }); 107 | 108 | describe('when "expire" is called', function() { 109 | var tickSpy; 110 | 111 | beforeEach(function() { 112 | expect(person.get('isExpired')).to.not.be.ok; 113 | person.set('resourceState', Ember.Resource.Lifecycle.FETCHED); 114 | expect(person.get('isFetchable')).to.not.be.ok; 115 | tickSpy = sinon.stub(Ember.Resource.Lifecycle.clock, 'tick'); 116 | person.expire(); 117 | }); 118 | 119 | afterEach(function() { 120 | Ember.Resource.Lifecycle.clock.tick.restore(); 121 | }); 122 | 123 | it('should expire the object', function(done) { 124 | Em.run.next(function() { 125 | expect(person.get('isExpired')).to.be.ok; 126 | done(); 127 | }); 128 | }); 129 | 130 | it('should result in the object becoming fetchable', function(done) { 131 | Em.run.next(function() { 132 | expect(person.get('isFetchable')).to.be.ok; 133 | done(); 134 | }); 135 | }); 136 | 137 | it('should not tick the ember resource clock', function(done) { 138 | Em.run.next(function() { 139 | expect(tickSpy.callCount).to.equal(0); 140 | done(); 141 | }); 142 | }); 143 | }); 144 | 145 | describe('on a destroyed object', function() { 146 | beforeEach(function() { 147 | person.destroy(); 148 | }); 149 | 150 | it('should not cause an error', function() { 151 | expect(function() { person.expire(); }).to.not.throw(); 152 | }); 153 | }); 154 | }); 155 | 156 | describe('Given an object that observes `isFetchable`', function() { 157 | var person, called, obj; 158 | 159 | beforeEach(function() { 160 | called = false; 161 | 162 | person = Person.create({ 163 | state: Ember.Resource.Lifecycle.FETCHED 164 | }); 165 | 166 | obj = Ember.Object.extend({ 167 | person: person, 168 | isFetchableDidChange: function() { 169 | called = true; 170 | }.observes('person.isFetchable') 171 | }).create(); 172 | }); 173 | 174 | describe('When we expire the person object', function() { 175 | beforeEach(function() { 176 | person.expireNow(); 177 | }); 178 | 179 | it('should call the observer', function() { 180 | expect(called).to.equal(true); 181 | }); 182 | }); 183 | }); 184 | 185 | }); 186 | -------------------------------------------------------------------------------- /spec/javascripts/lookUpTypeSpec.js: -------------------------------------------------------------------------------- 1 | describe('lookUpType', function() { 2 | 3 | afterEach(function() { 4 | var unstub = Ember.Resource.lookUpType.restore; 5 | unstub && unstub(); 6 | }); 7 | 8 | describe('by default', function() { 9 | var Type, 10 | lookup = Ember.lookup || window; 11 | 12 | beforeEach(function() { 13 | Type = Ember.Object.extend(); 14 | Type.toString = function() { return 'Type'; }; 15 | lookup.TestNamespace = { MyType: Type }; 16 | }); 17 | 18 | afterEach(function() { 19 | lookup.TestNamespace = undefined; 20 | }); 21 | 22 | it('looks up types as globals', function() { 23 | expect( Ember.Resource.lookUpType('TestNamespace.MyType') ).to.equal(Type); 24 | }); 25 | }); 26 | 27 | it('is used to look up type strings in schemas', function() { 28 | var Child = Ember.Resource.define({ schema: { id: Number }}); 29 | sinon.stub(Ember.Resource, 'lookUpType').returns(Child); 30 | 31 | var child = Ember.Resource.define({ 32 | schema: { 33 | child: { type: 'Child', nested: true } 34 | } 35 | }).create({}, { child: { id: 4 } }).get('child'); 36 | 37 | expect( child instanceof Child ).to.be.ok; 38 | expect(child.get('id')).to.equal(4); 39 | }); 40 | 41 | }); 42 | -------------------------------------------------------------------------------- /spec/javascripts/remoteExpirySpec.js: -------------------------------------------------------------------------------- 1 | describe('remote expiry', function() { 2 | var Resource; 3 | describe('on a resource with a remote expiry key', function() { 4 | beforeEach(function() { 5 | Resource = Ember.Resource.define().extend({ 6 | remoteExpiryKey: "foo" 7 | }); 8 | this.resource = Resource.create(); 9 | sinon.spy(this.resource, 'subscribeForExpiry'); 10 | }); 11 | 12 | it('should subscribe for expiry on fetch', function() { 13 | Ember.sendEvent(this.resource, 'didFetch'); 14 | Ember.run.sync(); 15 | expect(this.resource.subscribeForExpiry.callCount).to.equal(1); 16 | }); 17 | }); 18 | 19 | describe('on a resource with no remote expiry key', function() { 20 | beforeEach(function() { 21 | Resource = Ember.Resource.define().extend(); 22 | this.resource = Resource.create(); 23 | sinon.spy(this.resource, 'subscribeForExpiry'); 24 | }); 25 | 26 | it('should not subscribe for expiry on fetch', function() { 27 | Ember.sendEvent(this.resource, 'didFetch'); 28 | Ember.run.sync(); 29 | expect(this.resource.subscribeForExpiry.callCount).to.equal(0); 30 | }); 31 | }); 32 | 33 | describe("subscribing for expiry", function() { 34 | beforeEach(function() { 35 | Resource = Ember.Resource.define().extend({ 36 | remoteExpiryKey: "foo" 37 | }); 38 | this.resource = Resource.create(); 39 | this.spy = sinon.stub(Ember.Resource.PushTransport, 'subscribe'); 40 | Ember.sendEvent(this.resource, 'didFetch'); 41 | Ember.run.sync(); 42 | }); 43 | 44 | afterEach(function() { 45 | Ember.Resource.PushTransport.subscribe.restore(); 46 | }); 47 | 48 | it('should use the PushTransport', function() { 49 | expect(this.spy.callCount).to.equal(1); 50 | expect(this.spy.getCall(0).args[0]).to.equal('foo'); 51 | expect(typeof this.spy.getCall(0).args[1]).to.equal('function'); 52 | }); 53 | 54 | it('should subscribe to Ember.Resource.PushTransport', function() { 55 | expect(this.spy.callCount).to.equal(1); 56 | }); 57 | 58 | it('should not subscribe more than once', function() { 59 | Ember.sendEvent(this.resource, 'didFetch'); 60 | Ember.run.sync(); 61 | expect(this.spy.callCount).to.equal(1); 62 | }); 63 | }); 64 | 65 | describe("updating expiry", function() { 66 | beforeEach(function() { 67 | Resource = Ember.Resource.define().extend({ 68 | remoteExpiryKey: "foo" 69 | }); 70 | this.resource = Resource.create(); 71 | this.date = 1345511310; 72 | sinon.spy(this.resource, 'expire'); 73 | sinon.spy(this.resource, 'fetch'); 74 | }); 75 | 76 | it('should expire resource when stale', function() { 77 | this.resource.updateExpiry({ 78 | updatedAt: this.date 79 | }); 80 | expect(this.resource.expire.callCount).to.equal(1); 81 | }); 82 | 83 | it('should not expire resource when fresh', function() { 84 | this.resource.set('expiryUpdatedAt', 1345511310 + 200); 85 | this.resource.updateExpiry({ 86 | updatedAt: this.date 87 | }); 88 | expect(this.resource.expire.callCount).to.equal(0); 89 | }); 90 | 91 | it('should not expire resource when message is malformed', function() { 92 | this.resource.updateExpiry({}); 93 | expect(this.resource.expire.callCount).to.equal(0); 94 | }); 95 | 96 | describe("with remote expiry auto fetch", function() { 97 | beforeEach(function() { 98 | this.resource.set('remoteExpiryAutoFetch', true); 99 | }); 100 | 101 | it('should refetch resource when stale', function(done) { 102 | var resource = this.resource; 103 | 104 | Ember.run(function() { 105 | this.resource.updateExpiry({ 106 | updatedAt: this.date 107 | }); 108 | }.bind(this)); 109 | 110 | Ember.run(function() { 111 | expect(resource.get('isExpired')).to.be.ok; 112 | expect(resource.fetch.callCount).to.equal(1); 113 | expect(resource.expire.callCount).to.equal(0); 114 | done(); 115 | }); 116 | }); 117 | 118 | it('should not refetch resource when fresh', function() { 119 | this.resource.set('expiryUpdatedAt', 1345511310 + 200); 120 | this.resource.updateExpiry({ 121 | updatedAt: this.date 122 | }); 123 | expect(this.resource.expire.callCount).to.equal(0); 124 | expect(this.resource.fetch.callCount).to.equal(0); 125 | }); 126 | 127 | it('should not refetch resource when message is malformed', function() { 128 | this.resource.updateExpiry({}); 129 | expect(this.resource.expire.callCount).to.equal(0); 130 | expect(this.resource.fetch.callCount).to.equal(0); 131 | }); 132 | }); 133 | }); 134 | 135 | describe('unsubscribing on destroy', function() { 136 | beforeEach(function() { 137 | Resource = Ember.Resource.define().extend({ 138 | remoteExpiryKey: "foo" 139 | }); 140 | this.resource = Resource.create(); 141 | sinon.spy(Em.Resource.PushTransport, 'subscribe'); 142 | 143 | this.spy = sinon.stub(Ember.Resource.PushTransport, 'unsubscribe'); 144 | Ember.sendEvent(this.resource, 'didFetch'); 145 | Ember.run.sync(); 146 | 147 | }); 148 | 149 | it('should unsubscribe when destroyed', function() { 150 | Em.run(this, function() { 151 | this.resource.destroy(); 152 | }); 153 | expect(this.spy.callCount).to.equal(1); 154 | }); 155 | 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /spec/javascripts/resourceCollectionSpec.js: -------------------------------------------------------------------------------- 1 | /*globals Em*/ 2 | 3 | describe('ResourceCollection', function() { 4 | var Model; 5 | beforeEach(function() { 6 | Model = Em.Resource.define({ 7 | schema: { name: String }, 8 | url: '/url/from/resource' 9 | }); 10 | }); 11 | 12 | it("reads from its url property if present", function() { 13 | 14 | var collection = Em.ResourceCollection.extend({ 15 | type: Model, 16 | 17 | url: function() { 18 | return '/url/from/collection'; 19 | }.property() 20 | }).create(); 21 | 22 | expect(collection.resolveUrl()).to.equal('/url/from/collection'); 23 | }); 24 | 25 | describe('.parse', function() { 26 | it("uses the model's parse method", function() { 27 | Model.toString = function() { return 'Model'; }; 28 | Model.parse = function(json) { return { name: this + ' ' + json.name }; }; 29 | var collection = Em.ResourceCollection.create({ 30 | type: Model, 31 | content: [ { name: 'instance' } ] 32 | }); 33 | expect(collection.objectAt(0).get('name')).to.equal('Model instance'); 34 | }); 35 | }); 36 | 37 | describe("when prepopulated", function() { 38 | 39 | beforeEach(function() { 40 | this.collection = Em.ResourceCollection.create({ 41 | type: Object, 42 | content: [ { name: 'hello' } ] 43 | }); 44 | }); 45 | 46 | it('knows it is prePopulated', function() { 47 | expect(this.collection.get('prePopulated')).to.be.ok; 48 | }); 49 | 50 | it('returns a resolved deferred for #fetch', function() { 51 | var result = this.collection.fetch(); 52 | expect(result).not.to.be.undefined; 53 | expect(result.state()).to.equal('resolved'); 54 | }); 55 | 56 | }); 57 | 58 | describe('Given a ResourceCollection instance', function() { 59 | var instance; 60 | beforeEach(function() { 61 | instance = Em.ResourceCollection.create({ 62 | type: Model, 63 | url: '/url/from/collection' 64 | }); 65 | }); 66 | 67 | it('should be present in the identity map', function() { 68 | var id = instance.get('id'); 69 | expect(Em.ResourceCollection.identityMap.get(id)).to.be.ok; 70 | }); 71 | 72 | describe('when destroyed', function() { 73 | beforeEach(function() { 74 | instance.destroy(); 75 | }); 76 | 77 | it('should remove it from the identity map', function() { 78 | var id = instance.get('id'); 79 | expect(Em.ResourceCollection.identityMap.get(id)).to.not.be.ok; 80 | }); 81 | }); 82 | 83 | }); 84 | 85 | describe("isFresh", function() { 86 | var collection, server, isFresh; 87 | beforeEach(function() { 88 | isFresh = sinon.stub(); 89 | server = sinon.fakeServer.create(); 90 | server.respondWith("GET", "/people", 91 | [200, { "Content-Type": "application/json" }, 92 | JSON.stringify([{id: 1, name: "Foo"}]) ]); 93 | 94 | collection = Em.ResourceCollection.extend({ 95 | type: Model, 96 | 97 | url: function() { 98 | return '/people'; 99 | }.property(), 100 | 101 | isFresh: isFresh 102 | 103 | }).create(); 104 | 105 | }); 106 | 107 | afterEach(function() { 108 | server.restore(); 109 | }); 110 | 111 | describe("when data is fresh", function() { 112 | beforeEach(function() { 113 | isFresh.returns(true); 114 | }); 115 | 116 | it("should update data", function() { 117 | collection.fetch(); 118 | server.respond(); 119 | expect(collection.get("length")).to.equal(1); 120 | }); 121 | }); 122 | 123 | describe("when data is not fresh", function() { 124 | beforeEach(function() { 125 | isFresh.returns(false); 126 | }); 127 | 128 | it("should update data", function() { 129 | collection.fetch(); 130 | server.respond(); 131 | expect(collection.get("length")).to.equal(0); 132 | }); 133 | }); 134 | 135 | }); 136 | 137 | describe("with primitive wrapper object", function() { 138 | 139 | it("String should pass fetch()", function() { 140 | var server = sinon.fakeServer.create(); 141 | server.respondWith("GET", "/localizations", 142 | [200, { "Content-Type": "application/json" }, 143 | JSON.stringify(["en_US"]) ]); 144 | 145 | var collection = Em.ResourceCollection.extend({ 146 | type: String, 147 | url: function() { 148 | return '/localizations'; 149 | }.property() 150 | }).create(); 151 | 152 | collection.fetch(); 153 | server.respond(); 154 | expect(collection.get("length")).to.equal(1); 155 | 156 | server.restore(); 157 | }); 158 | 159 | it("String should pass toJSON()", function() { 160 | 161 | var collection = Em.ResourceCollection.extend({ 162 | type: String, 163 | url: function() { 164 | return '/localizations'; 165 | }.property() 166 | }).create({ content: [] }); 167 | 168 | collection.pushObject("en_US"); 169 | expect(collection.toJSON().length).to.equal(1); 170 | 171 | }); 172 | 173 | 174 | it("Number should pass fetch()", function() { 175 | var server = sinon.fakeServer.create(); 176 | server.respondWith("GET", "/localizations", 177 | [200, { "Content-Type": "application/json" }, 178 | JSON.stringify([1]) ]); 179 | 180 | var collection = Em.ResourceCollection.extend({ 181 | type: Number, 182 | url: function() { 183 | return '/localizations'; 184 | }.property() 185 | }).create(); 186 | 187 | collection.fetch(); 188 | server.respond(); 189 | expect(collection.get("length")).to.equal(1); 190 | 191 | server.restore(); 192 | }); 193 | 194 | it("Number should pass toJSON()", function() { 195 | 196 | var collection = Em.ResourceCollection.extend({ 197 | type: String, 198 | url: function() { 199 | return '/localizations'; 200 | }.property() 201 | }).create({ content: [] }); 202 | 203 | collection.pushObject(1); 204 | expect(collection.toJSON().length).to.equal(1); 205 | 206 | }); 207 | 208 | it("Boolean should pass fetch()", function() { 209 | var server = sinon.fakeServer.create(); 210 | server.respondWith("GET", "/localizations", 211 | [200, { "Content-Type": "application/json" }, 212 | JSON.stringify([true, false]) ]); 213 | 214 | var collection = Em.ResourceCollection.extend({ 215 | type: Boolean, 216 | url: function() { 217 | return '/localizations'; 218 | }.property() 219 | }).create(); 220 | 221 | collection.fetch(); 222 | server.respond(); 223 | expect(collection.get("length")).to.equal(2); 224 | 225 | server.restore(); 226 | }); 227 | 228 | it("Boolean should pass toJSON()", function() { 229 | 230 | var collection = Em.ResourceCollection.extend({ 231 | type: String, 232 | url: function() { 233 | return '/localizations'; 234 | }.property() 235 | }).create({ content: [] }); 236 | 237 | collection.pushObject(true); 238 | collection.pushObject(false); 239 | expect(collection.toJSON().length).to.equal(2); 240 | 241 | }); 242 | 243 | }); 244 | 245 | }); 246 | -------------------------------------------------------------------------------- /spec/javascripts/resourceSpec.js: -------------------------------------------------------------------------------- 1 | /*globals Ember*/ 2 | 3 | describe('A Resource instance', function () { 4 | var Model, model, server; 5 | 6 | beforeEach(function() { 7 | Model = Ember.Resource.define({ 8 | schema: { 9 | id: Number, 10 | name: String, 11 | subject: String 12 | }, 13 | url: '/people' 14 | }); 15 | }); 16 | 17 | describe("defining a resource", function() { 18 | describe("with a sideloads attribute", function() { 19 | var subject; 20 | beforeEach(function() { 21 | sinon.stub(Ember.Resource, 'ajax').returns($.when()); 22 | subject = Ember.Resource.define({ 23 | url: "/users", 24 | sideloads: ["abilities", "weapons"] 25 | }); 26 | }); 27 | 28 | afterEach(function() { 29 | Ember.Resource.ajax.restore(); 30 | }); 31 | 32 | it("should not include the sideloads in resourceURL", function() { 33 | var user = subject.create({id: 1}); 34 | expect(user.resourceURL()).to.equal("/users/1"); 35 | }); 36 | 37 | it("should send the sideloads in AJAX fetches", function() { 38 | var user = subject.create({id: 1}); 39 | user.fetch(); 40 | expect(Ember.Resource.ajax.callCount).to.equal(1); 41 | expect(Ember.Resource.ajax.getCall(0).args[0]).to.deep.equal({ 42 | url: "/users/1", 43 | resource: user, 44 | operation: 'read', 45 | data: {include: "abilities,weapons"} 46 | }); 47 | }); 48 | }); 49 | }); 50 | 51 | describe('with no ID', function() { 52 | beforeEach(function() { 53 | model = Model.create({}); 54 | }); 55 | 56 | it('does not fetch when setting an attribute', function() { 57 | sinon.spy(model, 'fetch'); 58 | model.set('name', 'Patricia'); 59 | expect(model.fetch.callCount).to.equal(0); 60 | }); 61 | 62 | it('allows setting a property to undefined', function() { 63 | model.set('name', 'Carlos'); 64 | expect(model.get('name')).to.equal('Carlos'); 65 | model.set('name', undefined); 66 | expect(model.get('name')).to.be.undefined; 67 | }); 68 | }); 69 | 70 | it('allows setting of properties not in the schema during creation', function() { 71 | model = Model.create({ 72 | undefinedProperty: 'foo' 73 | }); 74 | 75 | expect(model.get('undefinedProperty')).to.equal('foo'); 76 | }); 77 | 78 | it('allows setting functions during creation', function() { 79 | model = Model.create({ 80 | undefinedProperty: function() { return 'foo'; } 81 | }); 82 | 83 | expect(Ember.typeOf(model.undefinedProperty)).to.equal('function'); 84 | expect(model.undefinedProperty()).to.equal('foo'); 85 | }); 86 | 87 | it('allows setting observers during creation', function() { 88 | var observerDidFire = false; 89 | model = Model.create({ 90 | myObserver: function() { observerDidFire = true; }.observes('foo') 91 | }); 92 | observerDidFire = false; 93 | model.set('foo', 'new value'); 94 | 95 | expect(observerDidFire).to.equal(true); 96 | }); 97 | 98 | it('allows setting computed properties during creation', function() { 99 | model = Model.extend({ 100 | undefinedProperty: function() { return this.get('foo') + '!'; }.property('foo') 101 | }).create(); 102 | model.set('foo', 'foo'); 103 | 104 | expect(model.get('undefinedProperty')).to.equal('foo!'); 105 | }); 106 | 107 | it('allows setting of properties not in the schema during creation, considering paths', function() { 108 | Model = Ember.Resource.define({ 109 | schema: { 110 | id: Number, 111 | name: String, 112 | foo: {type: String, path: 'data.foo'} 113 | }, 114 | url: '/people' 115 | }); 116 | 117 | model = Model.create({ id: 1, undefinedProperty: 'foo', entry_id: 1, foo: 'bar' }); 118 | }); 119 | 120 | describe('updating objects already in identity map', function() { 121 | beforeEach(function() { 122 | model = Model.create({id: 1, name: 'blah'}); 123 | }); 124 | 125 | it('should update objects in the identity map with new data', function() { 126 | expect(model.get('subject')).to.be.undefined; 127 | model = Model.create({id: 1, name: 'boo', subject: 'bar'}); 128 | expect(model.get('name')).to.equal('boo'); 129 | expect(model.get('subject')).to.equal('bar'); 130 | }); 131 | }); 132 | 133 | describe('when setting a property value', function() { 134 | beforeEach(function() { 135 | model = Model.create({name: 'Aardvark'}); 136 | }); 137 | 138 | it('should execute callbacks with the property name and new value', function() { 139 | sinon.spy(model, 'resourcePropertyWillChange'); 140 | sinon.spy(model, 'resourcePropertyDidChange'); 141 | model.set('name', 'Zebra'); 142 | expect(model.resourcePropertyWillChange.calledWith('name', 'Zebra')).to.be.ok; 143 | expect(model.resourcePropertyDidChange.calledWith('name', 'Zebra')).to.be.ok; 144 | }); 145 | }); 146 | 147 | describe('Given a model with no data', function() { 148 | beforeEach(function() { 149 | model = Model.create(); 150 | model.set('data', undefined); 151 | expect(Ember.get(model, 'data')).to.be.undefined; 152 | }); 153 | 154 | describe('updating that model with api data', function() { 155 | it('should not blow up', function() { 156 | model.updateWithApiData({ foo: 'bar' }); 157 | }); 158 | }); 159 | }); 160 | 161 | describe('Given a model that expires five minutes from now', function() { 162 | beforeEach(function() { 163 | var now = new Date(), 164 | fiveMinutesFromNow = new Date(+now + 5 * 60 * 1000); 165 | model = Model.create(); 166 | Ember.run(model.set.bind(model, 'expireAt', fiveMinutesFromNow)); 167 | }); 168 | 169 | it('is not expired', function() { 170 | expect(model.get('isExpired')).to.not.be.ok; 171 | }); 172 | 173 | describe('Calling expire now', function() { 174 | beforeEach(function() { 175 | Ember.run(model.expireNow.bind(model)); 176 | }); 177 | 178 | it('expires the model', function() { 179 | expect(model.get('isExpired')).to.be.ok; 180 | }); 181 | }); 182 | 183 | describe('Calling refresh', function() { 184 | beforeEach(function() { 185 | sinon.spy(model, 'fetch'); 186 | sinon.spy(model, 'expireNow'); 187 | model.refresh(); 188 | }); 189 | 190 | it('expires the model', function() { 191 | expect(model.expireNow.callCount).to.equal(1); 192 | }); 193 | 194 | it('fetches the model', function() { 195 | expect(model.fetch.callCount).to.equal(1); 196 | }); 197 | }); 198 | }); 199 | 200 | describe("extending schema", function() { 201 | beforeEach(function() { 202 | Model.extendSchema({ 203 | description: String 204 | }); 205 | }); 206 | 207 | it("should change the schema", function() { 208 | model = Model.create({description: "Boo"}); 209 | expect(model.toJSON().description).to.equal("Boo"); 210 | }); 211 | }); 212 | 213 | describe("isFresh", function() { 214 | var isFresh; 215 | beforeEach(function() { 216 | server = sinon.fakeServer.create(); 217 | isFresh = sinon.stub(); 218 | server.respondWith("GET", "/people/1", 219 | [200, { "Content-Type": "application/json" }, 220 | JSON.stringify({id: 1, name: "Foo"}) ]); 221 | 222 | Model.reopen({ 223 | isFresh: isFresh 224 | }); 225 | 226 | model = Model.create({id: 1}); 227 | 228 | }); 229 | 230 | afterEach(function() { 231 | server.restore(); 232 | }); 233 | 234 | describe("when data is fresh", function() { 235 | beforeEach(function() { 236 | isFresh.returns(true); 237 | }); 238 | 239 | it("should update data", function() { 240 | model.fetch(); 241 | server.respond(); 242 | expect(model.get("name")).to.equal("Foo"); 243 | }); 244 | }); 245 | 246 | describe("when data is not fresh", function() { 247 | beforeEach(function() { 248 | isFresh.returns(false); 249 | }); 250 | 251 | it("should update data", function() { 252 | model.fetch(); 253 | server.respond(); 254 | 255 | expect(model.get("name")).to.be.undefined; 256 | }); 257 | }); 258 | }); 259 | 260 | describe("resource states", function() { 261 | beforeEach(function() { 262 | model = Model.create(); 263 | }); 264 | 265 | it("should be readonly", function() { 266 | [ 267 | 'isSaving', 268 | 'isSavable', 269 | 'isFetched', 270 | 'isFetching', 271 | 'isInitializing', 272 | 'isFetchable' 273 | ].forEach(function(state) { 274 | expect(function() { model.set(state, 'custom_value'); }).to.throw(Error); 275 | }); 276 | }); 277 | }); 278 | 279 | }); 280 | -------------------------------------------------------------------------------- /spec/javascripts/resourceURLSpec.js: -------------------------------------------------------------------------------- 1 | /*globals describe, it, beforeEach, expect, Em */ 2 | describe('resourceURL', function() { 3 | var subject, instance; 4 | describe("for a resource with a string #url", function() { 5 | beforeEach(function() { 6 | subject = Em.Resource.define({ 7 | url: "/users/me" 8 | }); 9 | }); 10 | 11 | describe("for an instance with an id", function() { 12 | beforeEach(function() { 13 | instance = subject.create({id: 1}); 14 | }); 15 | 16 | it("should append the id to the string", function() { 17 | expect(instance.resourceURL()).to.equal("/users/me/1"); 18 | }); 19 | }); 20 | 21 | describe("for an instance with no id", function() { 22 | beforeEach(function() { 23 | instance = subject.create(); 24 | }); 25 | 26 | it("should return the string", function() { 27 | expect(instance.resourceURL()).to.equal("/users/me"); 28 | }); 29 | }); 30 | 31 | describe('for an instance with ID 0', function() { 32 | beforeEach(function() { 33 | instance = subject.create({ id: 0 }); 34 | }); 35 | 36 | it("should not have a URL", function() { 37 | expect(instance.resourceURL()).to.be.undefined; 38 | }); 39 | }); 40 | 41 | describe('for an instance with a negative ID', function() { 42 | beforeEach(function() { 43 | instance = subject.create({ id: -1 }); 44 | }); 45 | 46 | it('should not have a URL', function() { 47 | expect(instance.resourceURL()).to.be.undefined; 48 | }); 49 | }); 50 | }); 51 | 52 | describe("for a resource with a function #url", function() { 53 | beforeEach(function() { 54 | subject = Em.Resource.define({ 55 | url: function(instance) { 56 | return "/users/%@".fmt(instance.get('id')); 57 | } 58 | }); 59 | }); 60 | 61 | describe("for an instance", function() { 62 | beforeEach(function() { 63 | instance = subject.create({id: 1}); 64 | }); 65 | it("should return the result of invoking the function with the instance", function() { 66 | sinon.spy(subject, 'url', subject.url.bind(subject)); 67 | 68 | expect(instance.resourceURL()).to.equal("/users/1"); 69 | expect(subject.url.calledWith(instance)).to.be.ok; 70 | }); 71 | }); 72 | 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /spec/javascripts/saveSpec.js: -------------------------------------------------------------------------------- 1 | describe('Saving a resource instance', function() { 2 | var getPath = Ember.Resource.getPath, 3 | Model, model, server; 4 | 5 | beforeEach(function() { 6 | Model = Ember.Resource.define({ 7 | schema: { 8 | id: Number, 9 | name: String, 10 | subject: String 11 | }, 12 | url: '/people' 13 | }); 14 | 15 | server = sinon.fakeServer.create(); 16 | }); 17 | 18 | afterEach(function() { 19 | server.restore(); 20 | Ember.Resource.errorHandler = null; 21 | }); 22 | 23 | describe('handling errors on save', function() { 24 | beforeEach(function() { 25 | server.respondWith('POST', '/people', [422, {}, '[["foo", "bar"]]']); 26 | }); 27 | 28 | it('should pass a reference to the resource to the error handling function', function() { 29 | var spy = sinon.spy(); 30 | Ember.Resource.errorHandler = function(a, b, c, fourthArgument) { 31 | spy(fourthArgument.resource, fourthArgument.operation); 32 | }; 33 | 34 | var resource = Model.create({ name: 'foo' }); 35 | resource.save(); 36 | server.respond(); 37 | 38 | expect(spy.calledWith(resource, "create")).to.be.ok; 39 | }); 40 | }); 41 | 42 | describe('handling errors on create', function() { 43 | beforeEach(function() { 44 | server.respondWith('PUT', '/people/1', [422, {}, '[["foo", "bar"]]']); 45 | }); 46 | 47 | it('should pass a reference to the resource to the error handling function', function() { 48 | var spy = sinon.spy(); 49 | Ember.Resource.errorHandler = function(a, b, c, fourthArgument) { 50 | spy(fourthArgument.resource, fourthArgument.operation); 51 | }; 52 | 53 | var resource = Model.create({ name: 'foo' }); 54 | resource.set('isNew', false); 55 | resource.set('id', 1); 56 | 57 | resource.save(); 58 | server.respond(); 59 | 60 | expect(spy.calledWith(resource, "update")).to.be.ok; 61 | }); 62 | }); 63 | 64 | describe('resourceState', function() { 65 | describe('saving', function() { 66 | var resource; 67 | 68 | beforeEach(function() { 69 | server.respondWith('POST', '/people', [201, {}, '{}']); 70 | resource = Model.create({ name: 'foo' }); 71 | expect(resource.get('resourceState')).not.to.equal(Ember.Resource.Lifecycle.SAVING); 72 | }); 73 | 74 | it('should change to the saving state while saving', function() { 75 | expect(resource.save()).to.be.ok; 76 | expect(resource.get('resourceState')).to.equal(Ember.Resource.Lifecycle.SAVING); 77 | }); 78 | 79 | it('should indicate that it is saving', function() { 80 | expect(resource.get('isSaving')).to.equal(false); 81 | expect(resource.save()).to.be.ok; 82 | expect(resource.get('isSaving')).to.equal(true); 83 | }); 84 | 85 | it('should change to previous state after save completes', function() { 86 | var previousState = resource.get('resourceState'); 87 | expect(resource.save()).to.be.ok; 88 | expect(resource.get('resourceState')).not.to.equal(previousState); 89 | server.respond(); 90 | expect(resource.get('resourceState')).to.equal(previousState); 91 | }); 92 | 93 | it('should not allow concurrent saves', function() { 94 | expect(resource.save().state()).to.equal('pending'); 95 | expect(resource.save().state()).to.equal('rejected'); 96 | server.respond(); 97 | expect(resource.save().state()).to.equal('pending'); 98 | }); 99 | 100 | it("should not allow setting the value of isSaving", function() { 101 | expect(resource.get('isSaving')).to.equal(false); 102 | expect(function() { resource.set('isSaving', 'custom_value'); }).to.throw(Error); 103 | }); 104 | }); 105 | 106 | }); 107 | 108 | describe('save callbacks:', function() { 109 | var resource, eventHandler; 110 | 111 | describe('when saving succeeds', function() { 112 | beforeEach(function() { 113 | resource = Model.create({ name: 'foo' }); 114 | sinon.spy(resource, 'didSave'); 115 | server.respondWith('POST', '/people', [201, {}, '{}']); 116 | resource.save(); 117 | server.respond(); 118 | }); 119 | 120 | it('should call "didSave"', function() { 121 | expect(resource.didSave.called).to.be.ok; 122 | }); 123 | }); 124 | 125 | describe('when saving fails', function() { 126 | beforeEach(function() { 127 | resource = Model.create({ name: 'foo' }); 128 | sinon.spy(resource, 'didSave'); 129 | server.respondWith('POST', '/people', [500, {}, '{}']); 130 | resource.save(); 131 | server.respond(); 132 | }); 133 | 134 | it('should call "didFail"', function() { 135 | expect(resource.didSave.called).to.not.be.ok; 136 | }); 137 | }); 138 | 139 | describe('when saving a new record', function() { 140 | beforeEach(function() { 141 | server.respondWith('POST', '/people', [201, {}, '{}']); 142 | resource = Model.create({ name: 'foo' }); 143 | sinon.spy(resource, 'didSave'); 144 | resource.save(); 145 | server.respond(); 146 | }); 147 | 148 | it('should pass created: true to didSave', function() { 149 | expect(resource.didSave.calledWith({created: true, data: {}})).to.be.ok; 150 | }); 151 | }); 152 | 153 | describe('when saving an existing record', function() { 154 | beforeEach(function() { 155 | server.respondWith('PUT', '/people/1', [200, {}, '{}']); 156 | resource = Model.create({ id: 1, name: 'foo' }); 157 | sinon.spy(resource, 'didSave'); 158 | resource.save(); 159 | server.respond(); 160 | }); 161 | 162 | it('should pass created: false to didSave', function() { 163 | expect(resource.didSave.calledWith({created: false, data: {}})).to.be.ok; 164 | }); 165 | }); 166 | 167 | describe('when saving a record', function() { 168 | var response = { people: { name: 'foo'}}; 169 | 170 | beforeEach(function() { 171 | server.respondWith('PUT', '/people/1', [200, {}, JSON.stringify(response)]); 172 | resource = Model.create({ id: 1, name: 'foo' }); 173 | sinon.spy(resource, 'didSave'); 174 | resource.save(); 175 | server.respond(); 176 | }); 177 | 178 | it('should pass returned data to didSave', function() { 179 | expect(resource.didSave.calledWith({created: false, data: response})).to.be.ok; 180 | }); 181 | }); 182 | 183 | }); 184 | 185 | describe('updating from response', function() { 186 | var resource; 187 | 188 | describe('with default Location header parsing', function() { 189 | beforeEach(function() { 190 | server.respondWith('POST', '/people', [201, {'Location': 'http://example.com/people/25.json'}, '{}']); 191 | resource = Model.create({ name: 'foo' }); 192 | }); 193 | 194 | it('should update with the id from the Location header', function() { 195 | resource.save(); 196 | server.respond(); 197 | expect(resource.get('id')).to.equal(25); 198 | }); 199 | }); 200 | 201 | describe('with a custom Location header parser', function() { 202 | beforeEach(function() { 203 | server.respondWith('POST', '/people', [201, {'Location': 'http://example.com/people/25.json'}, '{}']); 204 | Model.reopenClass({ 205 | idFromURL: function(url) { 206 | return 100; 207 | } 208 | }); 209 | 210 | resource = Model.create({ name: 'foo' }); 211 | }); 212 | 213 | it('should update with the id from the custom parser', function() { 214 | resource.save(); 215 | server.respond(); 216 | expect(resource.get('id')).to.equal(100); 217 | }); 218 | }); 219 | 220 | describe('from a response body', function() { 221 | beforeEach(function() { 222 | server.respondWith('POST', '/people', [201, { "Content-Type": "application/json" }, '{ "id": 1, "subject": "the subject" }']); 223 | resource = Model.create({ name: 'foo' }); 224 | }); 225 | 226 | it('should update with the data given', function() { 227 | resource.save(); 228 | server.respond(); 229 | expect(resource.get('id')).to.equal(1); 230 | expect(resource.get('subject')).to.equal('the subject'); 231 | expect(resource.get('name')).to.equal('foo'); 232 | }); 233 | 234 | it('should not update with the data if you pass the update: false option', function() { 235 | resource.save({update: false}); 236 | server.respond(); 237 | expect(resource.get('id')).to.be.undefined; 238 | expect(resource.get('subject')).to.be.undefined; 239 | expect(resource.get('name')).to.equal('foo'); 240 | }); 241 | 242 | describe('resource has one embedded association', function() { 243 | beforeEach(function() { 244 | var Address = Ember.Resource.define({ 245 | schema: { 246 | street: String, 247 | zip: Number, 248 | city: String 249 | } 250 | }); 251 | var Person = Ember.Resource.define({ 252 | schema: { 253 | id: Number, 254 | name: String, 255 | address: {type: Address, nested: true} 256 | }, 257 | url: '/persons' 258 | }); 259 | server.respondWith('POST', '/persons', [201, { "Content-Type": "application/json" }, '{ "id": 1, "address": { "street": "baz" } }']); 260 | resource = Person.create({ name: 'foo' }, { address: { street: 'bar' } }); 261 | }); 262 | 263 | it("should update with the data given", function() { 264 | resource.save(); 265 | server.respond(); 266 | expect(resource.get('id')).to.equal(1); 267 | expect(getPath(resource, 'address.street')).to.equal('baz'); 268 | }); 269 | }); 270 | }); 271 | 272 | }); 273 | 274 | describe('payload for save requests', function() { 275 | var resource; 276 | beforeEach(function() { 277 | resource = Model.create({ name: 'foo', id: 12, subject: 'hello world!' }); 278 | }); 279 | 280 | it('saves requested fields when specified', function() { 281 | resource.save({fields: ['name', 'id']}); 282 | expect(server.requests[0].requestBody).to.equal('{"name":"foo","id":12}'); 283 | }); 284 | 285 | it('saves all fields', function() { 286 | resource.save(); 287 | expect(server.requests[0].requestBody).to.equal('{"id":12,"name":"foo","subject":"hello world!"}'); 288 | }); 289 | }); 290 | }); 291 | -------------------------------------------------------------------------------- /spec/javascripts/schemaSpec.js: -------------------------------------------------------------------------------- 1 | describe('schema definition', function() { 2 | var getPath = Ember.Resource.getPath; 3 | 4 | describe('of attributes', function() { 5 | var Model; 6 | 7 | beforeEach(function() { 8 | Model = Ember.Resource.define({ 9 | schema: { 10 | id: {type: Number, path: 'somewhere.deep.id'}, 11 | size: {type: Number}, 12 | age: Number, 13 | name: String, 14 | birthday: Date, 15 | single: Boolean 16 | } 17 | }); 18 | }); 19 | 20 | it('should use the specified path if given', function() { 21 | expect(Model.schema.id.path).to.equal('somewhere.deep.id'); 22 | }); 23 | 24 | it('should use the attribute name as path if not specified', function() { 25 | expect(Model.schema.size.path).to.equal('size'); 26 | }); 27 | 28 | it('should support Number', function() { 29 | expect(Model.schema.age.get('type')).to.equal(Number); 30 | expect(Model.schema.age.path).to.equal('age'); 31 | }); 32 | 33 | it('should support String', function() { 34 | expect(Model.schema.name.get('type')).to.equal(String); 35 | expect(Model.schema.name.path).to.equal('name'); 36 | }); 37 | 38 | it('should support Date', function() { 39 | expect(Model.schema.birthday.get('type')).to.equal(Date); 40 | expect(Model.schema.birthday.path).to.equal('birthday'); 41 | }); 42 | 43 | it('should support Boolean', function() { 44 | expect(Model.schema.single.get('type')).to.equal(Boolean); 45 | expect(Model.schema.single.path).to.equal('single'); 46 | }); 47 | }); 48 | 49 | describe('of has-one associations', function() { 50 | var Model, Person, Address; 51 | 52 | beforeEach(function() { 53 | Address = Ember.Resource.define({ 54 | schema: { 55 | street: String, 56 | zip: Number 57 | } 58 | }); 59 | 60 | Model = Ember.Resource.define({ 61 | schema: { 62 | home_address: {type: Address}, 63 | work_address: {type: Address, path: 'work_addr_id'}, 64 | work_addr_id: String, 65 | other_address: {type: Address, nested: true} 66 | } 67 | }); 68 | }); 69 | 70 | it('should use the specified path when given', function() { 71 | expect(Model.schema.work_address.get('path')).to.equal('work_addr_id'); 72 | }); 73 | 74 | it('should guess a path from the name when not given', function() { 75 | expect(Model.schema.home_address.get('path')).to.equal('home_address_id'); 76 | }); 77 | 78 | it('should define a Number attribute at the path if not present', function() { 79 | expect(Model.schema.home_address_id).to.not.equal(undefined); 80 | expect(Model.schema.home_address_id.get('type')).to.equal(Number); 81 | }); 82 | 83 | it('should not override the attribute at the path if present', function() { 84 | expect(Model.schema.work_addr_id).to.not.equal(undefined); 85 | expect(Model.schema.work_addr_id.get('type')).to.equal(String); 86 | }); 87 | 88 | it('should create an *_id attribute for nested associations', function() { 89 | expect(Model.schema.other_address).to.not.equal(undefined); 90 | expect(Model.schema.other_address_id).to.not.equal(undefined); 91 | expect(Model.schema.other_address_id instanceof Ember.Resource.HasOneNestedIdSchemaItem).to.equal(true); 92 | expect(Model.schema.other_address_id.get('association')).to.equal(Model.schema.other_address); 93 | expect(Model.schema.other_address_id.get('path')).to.equal('other_address.id'); 94 | }); 95 | }); 96 | 97 | it('should create Number properties', function() { 98 | var Model = Ember.Resource.define({ 99 | schema: { 100 | id: Number, 101 | size: Number 102 | } 103 | }); 104 | var data = {id: 1, size: '5'}; 105 | var instance = Model.create({}, data); 106 | data = instance.get('data'); 107 | 108 | expect(instance.get('id')).to.equal(1); 109 | expect(instance.get('size')).to.equal(5); 110 | 111 | instance.set('id', '2'); 112 | expect(getPath(instance, 'data.id')).to.equal(2); 113 | 114 | instance.set('size', 3); 115 | expect(getPath(instance, 'data.size')).to.equal(3); 116 | 117 | instance.set('size', 'foo'); 118 | expect(instance.get('size')).to.be.undefined; 119 | expect(getPath(instance, 'data.size')).to.be.undefined; 120 | 121 | instance.set('size', NaN); 122 | expect(instance.get('size')).to.be.undefined; 123 | expect(getPath(instance, 'data.size')).to.be.undefined; 124 | }); 125 | 126 | it('should create String properties', function() { 127 | var Model = Ember.Resource.define({ 128 | schema: { 129 | id: String, 130 | size: String 131 | } 132 | }); 133 | var data = {id: 1, size: 'large'}; 134 | var instance = Model.create({}, data); 135 | data = instance.get('data'); 136 | 137 | expect(instance.get('id')).to.equal('1'); 138 | expect(instance.get('size')).to.equal('large'); 139 | 140 | instance.set('id', 2); 141 | expect(getPath(instance, 'data.id')).to.equal('2'); 142 | 143 | instance.set('size', 'small'); 144 | expect(getPath(instance, 'data.size')).to.equal('small'); 145 | }); 146 | 147 | it('should create Date properties', function() { 148 | var date = new Date(); 149 | var dateString = date.toJSON(); 150 | 151 | var Model = Ember.Resource.define({ 152 | schema: { 153 | createdAt: Date, 154 | updatedAt: Date 155 | } 156 | }); 157 | var data = {createdAt: dateString, updatedAt: date}; 158 | var instance = Model.create({}, data); 159 | data = instance.get('data'); 160 | 161 | expect(+instance.get('createdAt')).to.equal(+date); 162 | expect(+instance.get('updatedAt')).to.equal(+date); 163 | 164 | date = new Date(); 165 | dateString = date.toJSON(); 166 | 167 | instance.set('createdAt', date); 168 | expect(getPath(instance, 'data.createdAt')).to.equal(dateString, "convert a Date instance to a string"); 169 | 170 | instance.set('updatedAt', dateString); 171 | expect(getPath(instance, 'data.updatedAt')).to.equal(dateString, 'convert a string ("' + dateString + '") to a string'); 172 | 173 | instance.updateWithApiData({ createdAt: '' }); 174 | expect(getPath(instance, 'createdAt')).to.equal(''); 175 | 176 | instance.updateWithApiData({ createdAt: null }); 177 | expect(getPath(instance, 'createdAt')).to.equal(null); 178 | 179 | instance.updateWithApiData({ createdAt: undefined }); 180 | expect(getPath(instance, 'createdAt')).to.be.undefined; 181 | }); 182 | 183 | it('should create Boolean properties', function() { 184 | var Model = Ember.Resource.define({ 185 | schema: { 186 | 'public': Boolean, 187 | active: Boolean, 188 | good: Boolean, 189 | bad: Boolean 190 | } 191 | }); 192 | var data = {'public': true, active: false, good: 'true', bad: 'false'}; 193 | var instance = Model.create({}, data); 194 | data = instance.get('data'); 195 | 196 | expect(instance.get('public')).to.equal(true); 197 | expect(instance.get('active')).to.equal(false); 198 | expect(instance.get('good')).to.equal(true); 199 | expect(instance.get('bad')).to.equal(false); 200 | 201 | instance.set('public', 'true'); 202 | expect(getPath(instance, 'data.public')).to.equal(true, "convert 'true' to true"); 203 | 204 | instance.set('public', 'false'); 205 | expect(getPath(instance, 'data.public')).to.equal(false, "convert 'false' to false"); 206 | 207 | instance.set('public', true); 208 | expect(getPath(instance, 'data.public')).to.equal(true, "convert true to true"); 209 | 210 | instance.set('public', false); 211 | expect(getPath(instance, 'data.public')).to.equal(false, "convert false to false"); 212 | }); 213 | }); 214 | -------------------------------------------------------------------------------- /spec/javascripts/toJSONSpec.js: -------------------------------------------------------------------------------- 1 | describe('toJSON', function() { 2 | var setPath = (function() { 3 | var o = { object: {} }; 4 | Ember.set(o, 'object.path', 'value'); 5 | var setSupportsPath = o.object.path === 'value'; 6 | return setSupportsPath ? Ember.set : Ember.setPath; 7 | }()); 8 | 9 | it('should use toJSON of each of the schema items', function() { 10 | var Book = Ember.Resource.define({ 11 | schema: { 12 | id: Number, 13 | title: String 14 | } 15 | }); 16 | 17 | var book = Book.create({id: 1, title: 'Rework'}); 18 | 19 | sinon.stub(Book.schema.id, 'toJSON').returns(1); 20 | sinon.stub(Book.schema.title, 'toJSON').returns(undefined); 21 | 22 | var json = book.toJSON(); 23 | 24 | expect(Book.schema.id.toJSON.calledWith(book)).to.be.ok; 25 | expect(Book.schema.title.toJSON.calledWith(book)).to.be.ok; 26 | 27 | expect(json).to.deep.equal({id: 1}); 28 | }); 29 | 30 | describe('nested objects', function() { 31 | var Address = Ember.Resource.define({ 32 | schema: { 33 | city: String 34 | } 35 | }); 36 | 37 | var Person = Ember.Resource.define({ 38 | schema: { 39 | id: Number, 40 | name: String, 41 | address: { 42 | type: Address, 43 | nested: true 44 | } 45 | } 46 | }); 47 | 48 | var attributes = { 49 | name: 'John Smit', 50 | address: { 51 | city: 'London' 52 | } 53 | }; 54 | 55 | it('should return updated values of nested objects', function() { 56 | var person = Person.create(attributes), 57 | newCity = 'Liverpool', 58 | newName = 'Smit Johnson'; 59 | 60 | setPath(person, 'address.city', newCity); 61 | Ember.set(person, 'name', newName); 62 | 63 | var json = person.toJSON(); 64 | 65 | expect(json).to.deep.equal({ 66 | name: newName, 67 | address: { city: newCity } 68 | }); 69 | }); 70 | }); 71 | 72 | describe('remote has one associations', function() { 73 | var Address = Ember.Resource.define({ 74 | schema: { 75 | id: Number, 76 | city: String 77 | } 78 | }); 79 | 80 | var Person = Ember.Resource.define({ 81 | schema: { 82 | id: Number, 83 | name: String, 84 | address: { type: Address } 85 | } 86 | }); 87 | 88 | it('should return the id of the association at the path', function() { 89 | var address = Address.create({id: 1, city: 'San Francisco'}); 90 | var person = Person.create({id: 1, name: 'Mick Staugaard', address: address}); 91 | 92 | var json = person.toJSON(); 93 | expect(json.address).to.be.undefined; 94 | expect(json.address_id).to.equal(1); 95 | }); 96 | 97 | }); 98 | 99 | describe('remote has many associations', function() { 100 | var Book = Ember.Resource.define({ 101 | schema: { 102 | id: Number, 103 | title: String 104 | } 105 | }); 106 | 107 | var Library = Ember.Resource.define({ 108 | schema: { 109 | name: String, 110 | 111 | books: { 112 | type: Ember.ResourceCollection, 113 | itemType: Book, 114 | url: '/libraries/%@/books' 115 | } 116 | } 117 | }); 118 | 119 | it('should not be included', function() { 120 | var library = Library.create({name: 'The Robarts Library'}); 121 | setPath(library, 'books.content', [{ id: 1, title: 'The Hobbit' }]); 122 | expect(library.toJSON()).to.deep.equal({ name: 'The Robarts Library' }); 123 | }); 124 | }); 125 | 126 | describe('has many in array associations', function() { 127 | var Book = Ember.Resource.define({ 128 | schema: { 129 | id: Number, 130 | title: String 131 | } 132 | }); 133 | 134 | var Library = Ember.Resource.define({ 135 | schema: { 136 | name: String, 137 | 138 | books: { 139 | type: Ember.ResourceCollection, 140 | itemType: Book 141 | } 142 | } 143 | }); 144 | 145 | it('should include the ids of the items', function() { 146 | var library = Library.create({}, { 147 | name: 'The Robarts Library', 148 | books_ids: [1,2,3] 149 | }); 150 | 151 | expect(library.toJSON()).to.deep.equal({ 152 | name: 'The Robarts Library', 153 | books_ids: [1,2,3] 154 | }); 155 | }); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /spec/runner-next.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 57 | 58 | -------------------------------------------------------------------------------- /spec/runner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 57 | 58 | -------------------------------------------------------------------------------- /spec/test_helper.js: -------------------------------------------------------------------------------- 1 | /*globals Ember*/ 2 | 3 | Ember.Resource.Lifecycle.clock.stop(); 4 | -------------------------------------------------------------------------------- /src/base.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | window.Ember = window.Ember || window.SC; 4 | 5 | window.Ember.Resource = window.Ember.Object.extend({ 6 | resourcePropertyWillChange: window.Ember.K, 7 | resourcePropertyDidChange: window.Ember.K 8 | }); 9 | 10 | window.Ember.Resource.getPath = (function() { 11 | var o = { object: { path: 'value' } }, 12 | getSupportsPath = Ember.get(o, 'object.path') === 'value'; 13 | // Ember 1.0 : Ember 0.9 14 | return getSupportsPath ? Ember.get : Ember.getPath; 15 | }()); 16 | 17 | window.Ember.Resource.sendEvent = (function() { 18 | if (Ember.sendEvent.length === 2) { 19 | // If Ember 0.9, make an Ember 1.0-style function out of the 20 | // Ember 0.9 one: 21 | return function sendEvent(obj, eventName, params, actions) { 22 | Ember.warn("Ember.Resources.sendEvent can't do anything with actions on Ember 0.9", !actions); 23 | params = params || []; 24 | params.unshift(eventName); 25 | params.unshift(obj); 26 | return Ember.sendEvent.apply(Ember, params); 27 | }; 28 | } 29 | 30 | return function sendEvent(obj, eventName, params, actions) { 31 | return Ember.sendEvent(obj, eventName, params, actions); 32 | }; 33 | }()); 34 | 35 | }()); 36 | -------------------------------------------------------------------------------- /src/debug_adapter.js: -------------------------------------------------------------------------------- 1 | if (Ember.DataAdapter) { 2 | var get = Ember.get, capitalize = Ember.String.capitalize, underscore = Ember.String.underscore; 3 | 4 | var Resource = Ember.Resource; 5 | 6 | var DebugAdapter = Ember.DataAdapter.extend({ 7 | getFilters: function() { 8 | return [ 9 | { name: 'isInitializing', desc: 'Initializing' }, 10 | { name: 'isFetchable', desc: 'Fetchable' }, 11 | { name: 'isFetching', desc: 'Fetching' }, 12 | { name: 'isFetched', desc: 'Fetched' }, 13 | { name: 'isSaving', desc: 'Saving' }, 14 | { name: 'isSavable', desc: 'Savable' }, 15 | { name: 'isExpired', desc: 'Expired' } 16 | ]; 17 | }, 18 | 19 | detect: function(klass) { 20 | return klass !== Resource && Resource.detect(klass); 21 | }, 22 | 23 | columnsForType: function(type) { 24 | var columns = [], count = 0, limit = 10; 25 | for (var key in type.schema) { 26 | if (count++ === limit) { break; } 27 | columns.push({name: key, desc: key}); 28 | } 29 | return columns; 30 | }, 31 | 32 | getRecords: function(type) { 33 | var records = [], 34 | cache = type.identityMap && type.identityMap.cache, 35 | current = cache && cache.head; 36 | 37 | while (current) { 38 | records.push(current.value); 39 | current = current.newer; 40 | } 41 | 42 | return records; 43 | }, 44 | 45 | getRecordColumnValues: function(record) { 46 | return record.data; 47 | }, 48 | 49 | getRecordKeywords: function(record) { 50 | var keywords = []; 51 | for (var key in record.data) { 52 | keywords.push(record.data[key]); 53 | } 54 | return keywords; 55 | }, 56 | 57 | getRecordFilterValues: function(record) { 58 | return record.getProperties(this.getFilters().mapProperty('name')); 59 | }, 60 | 61 | getRecordColor: function(record) { 62 | var color = 'black'; 63 | if (record.get('isExpired')) { color = 'red'; } 64 | return color; 65 | }, 66 | 67 | observeRecord: function(record, recordUpdated) { 68 | /* 69 | TODO: Here's the code from Ember Data, we need to figure out how we want to observe records ourselves. 70 | 71 | var releaseMethods = Ember.A(), self = this, 72 | keysToObserve = Ember.A(['id', 'isNew', 'isDirty']); 73 | 74 | record.eachAttribute(function(key) { 75 | keysToObserve.push(key); 76 | }); 77 | 78 | keysToObserve.forEach(function(key) { 79 | var handler = function() { 80 | recordUpdated(self.wrapRecord(record)); 81 | }; 82 | Ember.addObserver(record, key, handler); 83 | releaseMethods.push(function() { 84 | Ember.removeObserver(record, key, handler); 85 | }); 86 | }); 87 | 88 | var release = function() { 89 | releaseMethods.forEach(function(fn) { fn(); } ); 90 | }; 91 | 92 | return release; 93 | */ 94 | return function() {}; 95 | } 96 | 97 | }); 98 | 99 | Ember.onLoad('Ember.Application', function(Application) { 100 | Application.initializer({ 101 | name: "dataAdapter", 102 | 103 | initialize: function(container, application) { 104 | application.register('dataAdapter:main', DebugAdapter); 105 | } 106 | }); 107 | }); 108 | } 109 | -------------------------------------------------------------------------------- /src/ember-resource.js: -------------------------------------------------------------------------------- 1 | (function(exports) { 2 | 3 | var expandSchema, expandSchemaItem, createSchemaProperties, 4 | mergeSchemas; 5 | 6 | var Ember = exports.Ember, 7 | getPath = Ember.Resource.getPath, 8 | set = Ember.set; 9 | 10 | // Determine if we need to be back compatible with Ember < 1.12 11 | var requiresEmberComputedPropertyFunction = false; 12 | try { 13 | Ember.computed({get: function() {}, set: function(key, value) {}}); 14 | } catch(ex) { 15 | requiresEmberComputedPropertyFunction = true; 16 | } 17 | 18 | function isString(obj) { 19 | return Ember.typeOf(obj) === 'string'; 20 | } 21 | 22 | function isNumber(obj) { 23 | return Ember.typeOf(obj) === 'number'; 24 | } 25 | 26 | function isBoolean(obj) { 27 | return Ember.typeOf(obj) === 'boolean'; 28 | } 29 | 30 | function isObject(obj) { 31 | return Ember.typeOf(obj) === 'object'; 32 | } 33 | 34 | function isFunction(obj) { 35 | return Ember.typeOf(obj) === 'function'; 36 | } 37 | 38 | // Used when evaluating schemas to turn a type String into a class. 39 | Ember.Resource.lookUpType = function(string) { 40 | return getPath(exports, string); 41 | }; 42 | 43 | Ember.Resource.deepSet = function(obj, path, value) { 44 | if (isString(path)) { 45 | Ember.Resource.deepSet(obj, path.split('.'), value); 46 | return; 47 | } 48 | 49 | var key = path.shift(); 50 | 51 | if (path.length === 0) { 52 | if (isObject(value)) { 53 | set(obj, key, Em.copy(value, true)); 54 | } else { 55 | set(obj, key, value); 56 | } 57 | } else { 58 | var newObj = getPath(obj, key); 59 | 60 | if (newObj === null || newObj === undefined) { 61 | newObj = {}; 62 | set(obj, key, newObj); 63 | } 64 | 65 | Ember.propertyWillChange(newObj, path); 66 | Ember.Resource.deepSet(newObj, path, value); 67 | Ember.propertyDidChange(newObj, path); 68 | } 69 | }; 70 | 71 | Ember.Resource.deepMerge = function(objA, objB) { 72 | var oldValue, newValue; 73 | 74 | for (var key in objB) { 75 | if (objB.hasOwnProperty(key)) { 76 | oldValue = getPath(objA, key); 77 | newValue = getPath(objB, key); 78 | 79 | if (isObject(newValue) && isObject(oldValue)) { 80 | Ember.propertyWillChange(objA, key); 81 | Ember.Resource.deepMerge(oldValue, newValue); 82 | Ember.propertyDidChange(objA, key); 83 | } else { 84 | set(objA, key, newValue); 85 | } 86 | } 87 | } 88 | }; 89 | 90 | Ember.Resource.AbstractSchemaItem = Ember.Object.extend({ 91 | name: String, // required 92 | getValue: Function, // required 93 | setValue: Function, // required 94 | 95 | dependencies: Ember.computed('path', function() { 96 | var deps = ['data.' + this.get('path')]; 97 | 98 | return deps; 99 | }), 100 | 101 | data: function(instance) { 102 | return getPath(instance, 'data'); 103 | }, 104 | 105 | type: Ember.computed('theType', function() { 106 | var type = this.get('theType'); 107 | if (isString(type)) { 108 | type = Ember.Resource.lookUpType(type); 109 | if (type) { 110 | this.set('theType', type); 111 | } else { 112 | type = this.get('theType'); 113 | } 114 | } 115 | return type; 116 | }), 117 | 118 | propertyFunction: (function(){ 119 | var _get = function(name) { 120 | var schemaItem = this.constructor.schema[name]; 121 | 122 | return schemaItem.getValue.call(schemaItem, this); 123 | }, 124 | _set = function(name, value) { 125 | var schemaItem = this.constructor.schema[name]; 126 | 127 | this.resourcePropertyWillChange(name, value); 128 | schemaItem.setValue.call(schemaItem, this, value); 129 | value = schemaItem.getValue.call(schemaItem, this); 130 | this.resourcePropertyDidChange(name, value); 131 | 132 | return value; 133 | }; 134 | 135 | return requiresEmberComputedPropertyFunction ? 136 | function(name, value) { 137 | if (arguments.length > 1) { 138 | return _set.apply(this, arguments); 139 | } else { 140 | return _get.apply(this, arguments); 141 | } 142 | } : { 143 | get: _get, 144 | set: _set 145 | }; 146 | })(), 147 | 148 | property: function() { 149 | var cp = new Ember.ComputedProperty(this.propertyFunction); 150 | return cp.property.apply(cp, this.get('dependencies')); 151 | }, 152 | 153 | toJSON: function(instance) { 154 | return undefined; 155 | } 156 | }); 157 | 158 | Ember.Resource.AbstractSchemaItem.reopenClass({ 159 | create: function(name, schema) { 160 | return this._super({name: name}); 161 | } 162 | }); 163 | 164 | 165 | Ember.Resource.SchemaItem = Ember.Resource.AbstractSchemaItem.extend({}); 166 | 167 | Ember.Resource.SchemaItem.reopenClass({ 168 | create: function(name, schema) { 169 | var definition = schema[name]; 170 | 171 | if (definition instanceof Ember.Resource.AbstractSchemaItem) { return definition; } 172 | 173 | var type; 174 | if (definition === Number || definition === String || definition === Boolean || definition === Date || definition === Object) { 175 | definition = {type: definition}; 176 | schema[name] = definition; 177 | } 178 | 179 | if (isObject(definition)) { 180 | type = definition.type; 181 | } 182 | 183 | if (type) { 184 | if (type.isEmberResource || isString(type)) { // a has-one association 185 | return Ember.Resource.HasOneSchemaItem.create(name, schema); 186 | } else if (type.isEmberResourceCollection) { // a has-many association 187 | return Ember.Resource.HasManySchemaItem.create(name, schema); 188 | } else { // a regular attribute 189 | return Ember.Resource.AttributeSchemaItem.create(name, schema); 190 | } 191 | } 192 | } 193 | }); 194 | 195 | Ember.Resource.AttributeSchemaItem = Ember.Resource.AbstractSchemaItem.extend({ 196 | theType: Object, 197 | path: String, // required 198 | 199 | getValue: function(instance) { 200 | var value; 201 | var data = this.data(instance); 202 | if (data) { 203 | value = getPath(data, this.get('path')); 204 | } 205 | 206 | if (this.typeCast) { 207 | value = this.typeCast(value); 208 | } 209 | 210 | return value; 211 | }, 212 | 213 | setValue: function(instance, value) { 214 | var data = this.data(instance); 215 | if (!data) return; 216 | 217 | if (this.typeCast) { 218 | value = this.typeCast(value); 219 | } 220 | if (value !== null && value !== undefined && Ember.typeOf(value.toJSON) == 'function') { 221 | value = value.toJSON(); 222 | } 223 | Ember.Resource.deepSet(data, this.get('path'), value); 224 | }, 225 | 226 | toJSON: function(instance) { 227 | return getPath(instance, this.name); 228 | } 229 | }); 230 | 231 | Ember.Resource.AttributeSchemaItem.reopenClass({ 232 | create: function(name, schema) { 233 | var definition = schema[name]; 234 | var instance; 235 | 236 | if (this === Ember.Resource.AttributeSchemaItem) { 237 | switch (definition.type) { 238 | case Number: 239 | return Ember.Resource.NumberAttributeSchemaItem.create(name, schema); 240 | case String: 241 | return Ember.Resource.StringAttributeSchemaItem.create(name, schema); 242 | case Boolean: 243 | return Ember.Resource.BooleanAttributeSchemaItem.create(name, schema); 244 | case Date: 245 | return Ember.Resource.DateAttributeSchemaItem.create(name, schema); 246 | default: 247 | instance = this._super.apply(this, arguments); 248 | instance.set('path', definition.path || name); 249 | return instance; 250 | } 251 | } 252 | else { 253 | instance = this._super.apply(this, arguments); 254 | instance.set('path', definition.path || name); 255 | return instance; 256 | } 257 | } 258 | }); 259 | 260 | Ember.Resource.NumberAttributeSchemaItem = Ember.Resource.AttributeSchemaItem.extend({ 261 | theType: Number, 262 | typeCast: function(value) { 263 | if (isNaN(value)) { 264 | value = undefined; 265 | } 266 | 267 | if (value === undefined || value === null || Ember.typeOf(value) === 'number') { 268 | return value; 269 | } else { 270 | return Number(value); 271 | } 272 | } 273 | }); 274 | 275 | Ember.Resource.StringAttributeSchemaItem = Ember.Resource.AttributeSchemaItem.extend({ 276 | theType: String, 277 | typeCast: function(value) { 278 | if (value === undefined || value === null || isString(value)) { 279 | return value; 280 | } else { 281 | return '' + value; 282 | } 283 | } 284 | }); 285 | 286 | Ember.Resource.BooleanAttributeSchemaItem = Ember.Resource.AttributeSchemaItem.extend({ 287 | theType: Boolean, 288 | typeCast: function(value) { 289 | if (value === undefined || value === null || Ember.typeOf(value) === 'boolean') { 290 | return value; 291 | } else { 292 | return value === 'true'; 293 | } 294 | } 295 | }); 296 | 297 | Ember.Resource.DateAttributeSchemaItem = Ember.Resource.AttributeSchemaItem.extend({ 298 | theType: Date, 299 | typeCast: function(value) { 300 | if (!value || Ember.typeOf(value) === 'date') { 301 | return value; 302 | } else { 303 | return new Date(value); 304 | } 305 | }, 306 | toJSON: function(instance) { 307 | var value = getPath(instance, this.name); 308 | return value ? value.toJSON() : value; 309 | } 310 | }); 311 | 312 | Ember.Resource.HasOneSchemaItem = Ember.Resource.AbstractSchemaItem.extend({ }); 313 | Ember.Resource.HasOneSchemaItem.reopenClass({ 314 | create: function(name, schema) { 315 | var definition = schema[name]; 316 | if (this === Ember.Resource.HasOneSchemaItem) { 317 | if (definition.nested) { 318 | return Ember.Resource.HasOneNestedSchemaItem.create(name, schema); 319 | } else { 320 | return Ember.Resource.HasOneRemoteSchemaItem.create(name, schema); 321 | } 322 | } 323 | else { 324 | var instance = this._super.apply(this, arguments); 325 | instance.set('theType', definition.type); 326 | if (definition.parse) { 327 | instance.set('parse', definition.parse); 328 | } 329 | return instance; 330 | } 331 | } 332 | }); 333 | 334 | Ember.Resource.HasOneNestedSchemaItem = Ember.Resource.HasOneSchemaItem.extend({ 335 | getValue: function(instance) { 336 | var data = this.data(instance); 337 | if (!data) return; 338 | var type = this.get('type'); 339 | var value = getPath(data, this.get('path')); 340 | if (value) { 341 | value = (this.get('parse') || type.parse).call(type, Ember.copy(value)); 342 | return type.create({}, value); 343 | } 344 | return value; 345 | }, 346 | 347 | setValue: function(instance, value) { 348 | var data = this.data(instance); 349 | if (!data) return; 350 | 351 | if (value instanceof this.get('type')) { 352 | // Copying value data containing an id might be dangerous 353 | // If a subsequent fetch call only updates the id in the data.path hash, 354 | // next time a instance.get(path) call is made, it would fetch id from 355 | // the identityMap and update with whatever is present in data.path hash 356 | var valueId = getPath(value ,'id'); 357 | if (valueId) { 358 | set(instance, this.get('name') + '_id', valueId); 359 | } else { 360 | Ember.Resource.deepSet(data, this.get('path'), getPath(value, 'data')); 361 | } 362 | } else { 363 | Ember.Resource.deepSet(data, this.get('path'), value); 364 | } 365 | }, 366 | 367 | toJSON: function(instance) { 368 | var value = getPath(instance, this.name); 369 | return value ? value.toJSON() : value; 370 | } 371 | }); 372 | Ember.Resource.HasOneNestedSchemaItem.reopenClass({ 373 | create: function(name, schema) { 374 | var definition = schema[name]; 375 | var instance = this._super.apply(this, arguments); 376 | instance.set('path', definition.path || name); 377 | 378 | var id_name = name + '_id'; 379 | if (!schema[id_name]) { 380 | schema[id_name] = {type: Number, association: instance }; 381 | schema[id_name] = Ember.Resource.HasOneNestedIdSchemaItem.create(id_name, schema); 382 | } 383 | 384 | return instance; 385 | } 386 | }); 387 | Ember.Resource.HasOneNestedIdSchemaItem = Ember.Resource.AbstractSchemaItem.extend({ 388 | theType: Number, 389 | getValue: function(instance) { 390 | return getPath(instance, this.get('path')); 391 | }, 392 | setValue: function(instance, value) { 393 | if(value == null) { 394 | set(instance, getPath(this, 'association.name'), null); 395 | } else { 396 | set(instance, getPath(this, 'association.name'), {id: value}); 397 | } 398 | } 399 | }); 400 | Ember.Resource.HasOneNestedIdSchemaItem.reopenClass({ 401 | create: function(name, schema) { 402 | var definition = schema[name]; 403 | var instance = this._super.apply(this, arguments); 404 | instance.set('association', definition.association); 405 | instance.set('path', definition.association.get('path') + '.id'); 406 | return instance; 407 | } 408 | }); 409 | 410 | 411 | Ember.Resource.HasOneRemoteSchemaItem = Ember.Resource.HasOneSchemaItem.extend({ 412 | getValue: function(instance) { 413 | var data = this.data(instance); 414 | if (!data) return; 415 | var id = getPath(data, this.get('path')); 416 | if (id) { 417 | return this.get('type').create({}, {id: id}); 418 | } 419 | }, 420 | 421 | setValue: function(instance, value) { 422 | var data = this.data(instance); 423 | if (!data) return; 424 | var id = getPath(value || {}, 'id'); 425 | Ember.Resource.deepSet(data, this.get('path'), id); 426 | } 427 | }); 428 | Ember.Resource.HasOneRemoteSchemaItem.reopenClass({ 429 | create: function(name, schema) { 430 | var definition = schema[name]; 431 | var instance = this._super.apply(this, arguments); 432 | var path = definition.path || name + '_id'; 433 | instance.set('path', path); 434 | 435 | if (!schema[path]) { 436 | schema[path] = Number; 437 | schema[path] = Ember.Resource.SchemaItem.create(path, schema); 438 | } 439 | 440 | return instance; 441 | } 442 | }); 443 | 444 | 445 | Ember.Resource.HasManySchemaItem = Ember.Resource.AbstractSchemaItem.extend({ 446 | itemType: Ember.computed('theItemType', function() { 447 | var type = this.get('theItemType'); 448 | if (isString(type)) { 449 | type = Ember.Resource.lookUpType(type); 450 | if (type) { 451 | this.set('theItemType', type); 452 | } else { 453 | type = this.get('theItemType'); 454 | } 455 | } 456 | return type; 457 | }) 458 | }); 459 | 460 | Ember.Resource.HasManySchemaItem.reopenClass({ 461 | create: function(name, schema) { 462 | var definition = schema[name]; 463 | if (this === Ember.Resource.HasManySchemaItem) { 464 | if (definition.url) { 465 | return Ember.Resource.HasManyRemoteSchemaItem.create(name, schema); 466 | } else if (definition.nested) { 467 | return Ember.Resource.HasManyNestedSchemaItem.create(name, schema); 468 | } else { 469 | return Ember.Resource.HasManyInArraySchemaItem.create(name, schema); 470 | } 471 | } else { 472 | var instance = this._super.apply(this, arguments); 473 | instance.set('theType', definition.type); 474 | instance.set('theItemType', definition.itemType); 475 | if (definition.parse) { 476 | instance.set('parse', definition.parse); 477 | } 478 | return instance; 479 | } 480 | } 481 | }); 482 | 483 | Ember.Resource.HasManyRemoteSchemaItem = Ember.Resource.HasManySchemaItem.extend({ 484 | dependencies: ['id', 'isInitializing'], 485 | getValue: function(instance) { 486 | if (getPath(instance, 'isInitializing')) return; 487 | 488 | var options = { 489 | type: this.get('itemType') 490 | }; 491 | 492 | if (this.get('parse')) options.parse = this.get('parse'); 493 | 494 | var url = this.url(instance); 495 | if (url) { 496 | options.url = url; 497 | } else { 498 | options.content = []; 499 | } 500 | 501 | return this.get('type').create(options); 502 | }, 503 | 504 | setValue: function(instance, value) { 505 | throw new Error('you can not set a remote has many association'); 506 | } 507 | }); 508 | Ember.Resource.HasManyRemoteSchemaItem.reopenClass({ 509 | create: function(name, schema) { 510 | var definition = schema[name]; 511 | 512 | var instance = this._super.apply(this, arguments); 513 | 514 | if (Ember.typeOf(definition.url) === 'function') { 515 | instance.url = definition.url; 516 | } else { 517 | instance.url = function(obj) { 518 | var id = obj.get('id'); 519 | if (id) { 520 | return definition.url.fmt(id); 521 | } 522 | }; 523 | } 524 | 525 | return instance; 526 | } 527 | }); 528 | 529 | Ember.Resource.HasManyNestedSchemaItem = Ember.Resource.HasManySchemaItem.extend({ 530 | getValue: function(instance) { 531 | var data = this.data(instance); 532 | if (!data) return; 533 | data = getPath(data, this.get('path')); 534 | if (data === undefined || data === null) return data; 535 | data = Ember.copy(data); 536 | 537 | var options = { 538 | type: this.get('itemType'), 539 | content: data 540 | }; 541 | 542 | if (this.get('parse')) options.parse = this.get('parse'); 543 | 544 | return this.get('type').create(options); 545 | }, 546 | 547 | setValue: function(instance, value) { 548 | }, 549 | 550 | toJSON: function(instance) { 551 | var value = getPath(instance, this.name); 552 | return value ? value.toJSON() : value; 553 | } 554 | }); 555 | Ember.Resource.HasManyNestedSchemaItem.reopenClass({ 556 | create: function(name, schema) { 557 | var definition = schema[name]; 558 | 559 | var instance = this._super.apply(this, arguments); 560 | instance.set('path', definition.path || name); 561 | 562 | return instance; 563 | } 564 | }); 565 | 566 | Ember.Resource.HasManyInArraySchemaItem = Ember.Resource.HasManySchemaItem.extend({ 567 | getValue: function (instance) { 568 | var data = this.data(instance); 569 | if (!data) return; 570 | data = getPath(data, this.get('path')); 571 | if (data === undefined || data === null) return data; 572 | 573 | 574 | return this.get('type').create({ 575 | type: this.get('itemType'), 576 | content: data.map(function(id) { return {id: id}; }) 577 | }); 578 | }, 579 | 580 | setValue: function(instance, value) { 581 | }, 582 | 583 | toJSON: function(instance) { 584 | var value = getPath(instance, this.name); 585 | return value ? value.mapProperty('id') : value; 586 | } 587 | }); 588 | Ember.Resource.HasManyInArraySchemaItem.reopenClass({ 589 | create: function(name, schema) { 590 | var definition = schema[name]; 591 | 592 | var instance = this._super.apply(this, arguments); 593 | instance.set('path', definition.path || name + '_ids'); 594 | 595 | return instance; 596 | } 597 | }); 598 | 599 | 600 | Ember.Resource.Lifecycle = { 601 | INITIALIZING: 0, 602 | UNFETCHED: 10, 603 | EXPIRING: 20, 604 | EXPIRED: 30, 605 | FETCHING: 40, 606 | FETCHED: 50, 607 | SAVING: 60, 608 | DESTROYING: 70, 609 | DESTROYED: 80, 610 | 611 | clock: Ember.Object.create({ 612 | now: new Date(), 613 | 614 | tick: function() { 615 | Ember.Resource.Lifecycle.clock.set('now', new Date()); 616 | }, 617 | 618 | start: function() { 619 | this.stop(); 620 | Ember.Resource.Lifecycle.clock.set('timer', setInterval(Ember.Resource.Lifecycle.clock.tick, 10000)); 621 | }, 622 | 623 | stop: function() { 624 | var timer = Ember.Resource.Lifecycle.clock.get('timer'); 625 | if (timer) { 626 | clearInterval(timer); 627 | } 628 | } 629 | }), 630 | 631 | classMixin: Ember.Mixin.create({ 632 | create: function(options, data) { 633 | options = options || {}; 634 | options.resourceState = Ember.Resource.Lifecycle.INITIALIZING; 635 | 636 | var instance = this._super.apply(this, arguments); 637 | 638 | if (getPath(instance, 'resourceState') === Ember.Resource.Lifecycle.INITIALIZING) { 639 | set(instance, 'resourceState', Ember.Resource.Lifecycle.UNFETCHED); 640 | } 641 | 642 | return instance; 643 | }, 644 | 645 | didEvictFromIdentityMap: function(entry) { 646 | var fn = Em.Resource.identityMapEvictionHandler; 647 | fn && fn.call(this, entry.value); 648 | } 649 | 650 | }), 651 | 652 | prototypeMixin: Ember.Mixin.create({ 653 | expireIn: 60 * 5, 654 | resourceState: 0, 655 | 656 | init: function() { 657 | this._super.apply(this, arguments); 658 | 659 | var resourceStateBeforeSave; 660 | var updateExpiry = function () { 661 | var expireAt = new Date(); 662 | expireAt.setSeconds(expireAt.getSeconds() + getPath(this, 'expireIn')); 663 | set(this, 'expireAt', expireAt); 664 | }; 665 | 666 | this._listenerHandlers = { 667 | willFetch: function() { 668 | set(this, 'resourceState', Ember.Resource.Lifecycle.FETCHING); 669 | updateExpiry.call(this); 670 | }, 671 | didFetch: function() { 672 | if(!getPath(this, 'hasBeenFetched')) { 673 | set(this, 'hasBeenFetched', true); 674 | } 675 | set(this, 'resourceState', Ember.Resource.Lifecycle.FETCHED); 676 | updateExpiry.call(this); 677 | }, 678 | didFail: function() { 679 | set(this, 'resourceState', Ember.Resource.Lifecycle.UNFETCHED); 680 | updateExpiry.call(this); 681 | }, 682 | willSave: function() { 683 | resourceStateBeforeSave = getPath(this, 'resourceState'); 684 | set(this, 'resourceState', Ember.Resource.Lifecycle.SAVING); 685 | }, 686 | didSave: function() { 687 | set(this, 'resourceState', resourceStateBeforeSave || Ember.Resource.Lifecycle.UNFETCHED); 688 | } 689 | }; 690 | 691 | Ember.addListener(this, 'willFetch', this, this._listenerHandlers.willFetch); 692 | Ember.addListener(this, 'didFetch', this, this._listenerHandlers.didFetch); 693 | Ember.addListener(this, 'didFail', this, this._listenerHandlers.didFail); 694 | Ember.addListener(this, 'willSave', this, this._listenerHandlers.willSave); 695 | Ember.addListener(this, 'didSave', this, this._listenerHandlers.didSave); 696 | }, 697 | 698 | isFetchable: Ember.computed(function(key) { 699 | var state = getPath(this, 'resourceState'); 700 | return state == Ember.Resource.Lifecycle.UNFETCHED || this.get('isExpired'); 701 | }).volatile().readOnly(), 702 | 703 | isInitializing: Ember.computed('resourceState', function (key) { 704 | return (getPath(this, 'resourceState') || Ember.Resource.Lifecycle.INITIALIZING) === Ember.Resource.Lifecycle.INITIALIZING; 705 | }).readOnly(), 706 | 707 | isFetching: Ember.computed('resourceState', function(key) { 708 | return (getPath(this, 'resourceState')) === Ember.Resource.Lifecycle.FETCHING; 709 | }).readOnly(), 710 | 711 | isFetched: Ember.computed('resourceState', function(key) { 712 | return (getPath(this, 'resourceState')) === Ember.Resource.Lifecycle.FETCHED; 713 | }).readOnly(), 714 | 715 | 716 | hasBeenFetched: false, 717 | 718 | isSavable: Ember.computed('resourceState', function(key) { 719 | var state = getPath(this, 'resourceState'); 720 | var unsavableState = [ 721 | Ember.Resource.Lifecycle.INITIALIZING, 722 | Ember.Resource.Lifecycle.FETCHING, 723 | Ember.Resource.Lifecycle.SAVING, 724 | Ember.Resource.Lifecycle.DESTROYING 725 | ]; 726 | 727 | return state && !unsavableState.contains(state); 728 | }).readOnly(), 729 | 730 | isSaving: Ember.computed('resourceState', function(key) { 731 | return (getPath(this, 'resourceState')) === Ember.Resource.Lifecycle.SAVING; 732 | }).readOnly(), 733 | 734 | // Notify dependents on volatile properties 735 | resourceStateDidChange: function() { 736 | this.notifyPropertyChange('isFetchable'); 737 | }.observes('resourceState'), 738 | 739 | expireAtDidChange: function() { 740 | this.notifyPropertyChange('isExpired'); 741 | this.notifyPropertyChange('isFetchable'); 742 | }.observes('expireAt'), 743 | 744 | expire: function () { 745 | Ember.run.next(this, function () { 746 | if(getPath(this, 'isDestroyed')) { return; } 747 | this.expireNow(); 748 | }); 749 | }, 750 | 751 | expireNow: function() { 752 | set(this, 'expireAt', new Date()); 753 | }, 754 | 755 | refresh: function() { 756 | this.expireNow(); 757 | return this.fetch(); 758 | }, 759 | 760 | isExpired: Ember.computed(function(name) { 761 | var expireAt = this.get('expireAt'); 762 | var now = new Date(); 763 | 764 | return !!(expireAt && expireAt.getTime() <= now.getTime()); 765 | }).volatile().readOnly(), 766 | 767 | isFresh: function(data) { 768 | return true; 769 | }, 770 | 771 | destroy: function() { 772 | var id = this.get('id'); 773 | 774 | if (id && this.constructor.identityMap) { 775 | if (this === this.constructor.identityMap.get(id)) { 776 | this.constructor.identityMap.remove(id); 777 | } 778 | } 779 | this._super(); 780 | }, 781 | 782 | willDestroy: function() { 783 | Ember.removeListener(this, 'willFetch', this, this._listenerHandlers.willFetch); 784 | Ember.removeListener(this, 'didFetch', this, this._listenerHandlers.didFetch); 785 | Ember.removeListener(this, 'didFail', this, this._listenerHandlers.didFail); 786 | Ember.removeListener(this, 'willSave', this, this._listenerHandlers.willSave); 787 | Ember.removeListener(this, 'didSave', this, this._listenerHandlers.didSave); 788 | } 789 | }) 790 | }; 791 | 792 | Ember.Resource.reopen({ 793 | isEmberResource: true, 794 | 795 | updateWithApiData: function(json) { 796 | var data = getPath(this, 'data'), parsedData; 797 | 798 | if (!data) { return; } 799 | 800 | parsedData = this.constructor.parse(json); 801 | 802 | if(!this.isFresh(parsedData)) { return; } 803 | 804 | Ember.beginPropertyChanges(); 805 | try { 806 | Ember.Resource.deepMerge(data, parsedData); 807 | } catch (ex) { 808 | Ember.Resource.logger && typeof Ember.Resource.logger.error === "function" && Ember.Resource.logger.error(ex); 809 | } finally { 810 | Ember.endPropertyChanges(); 811 | } 812 | }, 813 | 814 | willFetch: function() {}, 815 | didFetch: function() {}, 816 | willSave: function() {}, 817 | didSave: function() {}, 818 | didFail: function() {}, 819 | 820 | fetched: function() { 821 | if (!this._fetchDfd) { 822 | this._fetchDfd = $.Deferred(); 823 | } 824 | return this._fetchDfd; 825 | }, 826 | 827 | fetch: function(ajaxOptions) { 828 | var sideloads; 829 | 830 | if (this.deferredFetch && !getPath(this, 'isExpired')) { 831 | return this.deferredFetch.promise(); 832 | } 833 | 834 | if (!getPath(this, 'isFetchable')) return $.when(this.get('data'), this); 835 | 836 | var url = this.resourceURL(); 837 | 838 | if (!url) return $.when(this.get('data'), this); 839 | 840 | var self = this; 841 | 842 | self.willFetch.call(self); 843 | Ember.Resource.sendEvent(self, 'willFetch'); 844 | 845 | ajaxOptions = $.extend({}, ajaxOptions, { 846 | url: url, 847 | resource: this, 848 | operation: 'read' 849 | }); 850 | 851 | sideloads = this.constructor.sideloads; 852 | 853 | if (sideloads && sideloads.length !== 0) { 854 | ajaxOptions.data = {include: sideloads.join(",")}; 855 | } 856 | 857 | var result = this.deferredFetch = $.Deferred(); 858 | 859 | Ember.Resource.fetch(this, ajaxOptions) 860 | .done(function(json) { 861 | if (self.get('isDestroying') || self.get('isDestroyed')) { 862 | Ember.Resource.sendEvent(self, 'didFail'); 863 | result.reject(); 864 | self.fetched().reject(); 865 | return; 866 | } 867 | self.updateWithApiData(json); 868 | self.didFetch.call(self); 869 | Ember.Resource.sendEvent(self, 'didFetch'); 870 | self.fetched().resolve(json, self); 871 | result.resolve(json, self); 872 | }) 873 | .fail(function() { 874 | self.didFail.call(self); 875 | Ember.Resource.sendEvent(self, 'didFail'); 876 | var fetched = self.fetched(); 877 | fetched.reject.apply(fetched, arguments); 878 | result.reject.apply(result, arguments); 879 | }). 880 | always(function() { 881 | self.deferredFetch = null; 882 | }); 883 | 884 | return result.promise(); 885 | }, 886 | 887 | resourceURL: function() { 888 | return this.constructor.resourceURL(this); 889 | }, 890 | 891 | // Turn this resource into a JSON object to be saved via AJAX. Override 892 | // this method to produce different syncing behavior. 893 | // 894 | // Note: toJSON when called from within a collection iteration will receive several arguments, 895 | // the first one being the index. Therefore we need to make some validation to be sure we are 896 | // looking at a legitimate fields option before using it. 897 | toJSON: function(options) { 898 | var json = {}; 899 | var schemaItem, path, value; 900 | var schemaFields = Object.keys(this.constructor.schema); 901 | var fieldsToSet = options && options.fields ? options.fields.filter(function(schemaField) { 902 | return schemaFields.indexOf(schemaField) !== -1; 903 | }) : schemaFields; 904 | fieldsToSet.forEach(function(name) { 905 | schemaItem = this.constructor.schema[name]; 906 | if (schemaItem instanceof Ember.Resource.AbstractSchemaItem) { 907 | path = schemaItem.get('path'); 908 | value = schemaItem.toJSON(this); 909 | if (value !== undefined) { 910 | Ember.Resource.deepSet(json, path, value); 911 | } 912 | } 913 | }, this); 914 | return json; 915 | }, 916 | 917 | isNew: Ember.computed('id', function() { 918 | return !getPath(this, 'id'); 919 | }), 920 | 921 | save: function(options) { 922 | options = options || {}; 923 | if (!getPath(this, 'isSavable')) { 924 | return $.Deferred().reject(false); 925 | } 926 | 927 | var ajaxOptions = $.extend({}, options, { 928 | contentType: 'application/json', 929 | data: JSON.stringify(this.toJSON(options)), 930 | resource: this 931 | }); 932 | // delete local options 933 | delete ajaxOptions.update; 934 | 935 | var isCreate = getPath(this, 'isNew'); 936 | 937 | if (isCreate) { 938 | ajaxOptions.type = 'POST'; 939 | ajaxOptions.url = this.constructor.resourceURL(); 940 | ajaxOptions.operation = 'create'; 941 | } else { 942 | ajaxOptions.type = 'PUT'; 943 | ajaxOptions.url = this.resourceURL(); 944 | ajaxOptions.operation = 'update'; 945 | } 946 | 947 | var self = this; 948 | 949 | self.willSave.call(self); 950 | Ember.Resource.sendEvent(self, 'willSave'); 951 | 952 | var deferedSave = Ember.Resource.ajax(ajaxOptions); 953 | 954 | deferedSave.done(function(data, status, response) { 955 | var location = response.getResponseHeader('Location'); 956 | if (location) { 957 | var id = self.constructor.idFromURL(location); 958 | if (id) { 959 | set(self, 'id', id); 960 | } 961 | } 962 | 963 | if (options.update !== false && isObject(data)) { 964 | self.updateWithApiData(data); 965 | } 966 | 967 | self.didSave.call(self, {created: isCreate, data: data}); 968 | Ember.Resource.sendEvent(self, 'didSave', [{created: isCreate, data: data}]); 969 | 970 | }).fail(function() { 971 | self.didFail.call(self); 972 | Ember.Resource.sendEvent(self, 'didFail'); 973 | }); 974 | 975 | return deferedSave; 976 | }, 977 | 978 | destroyResource: function() { 979 | var previousState = getPath(this, 'resourceState'), self = this; 980 | set(this, 'resourceState', Ember.Resource.Lifecycle.DESTROYING); 981 | 982 | return Ember.Resource.ajax({ 983 | type: 'DELETE', 984 | operation: 'destroy', 985 | url: this.resourceURL(), 986 | resource: this 987 | }).done(function() { 988 | set(self, 'resourceState', Ember.Resource.Lifecycle.DESTROYED); 989 | Em.run.next(function() { 990 | self.destroy(); 991 | }); 992 | }).fail(function() { 993 | set(self, 'resourceState', previousState); 994 | }); 995 | } 996 | }, Ember.Resource.Lifecycle.prototypeMixin); 997 | 998 | expandSchema = function(schema) { 999 | for (var name in schema) { 1000 | if (schema.hasOwnProperty(name)) { 1001 | schema[name] = Ember.Resource.SchemaItem.create(name, schema); 1002 | } 1003 | } 1004 | 1005 | return schema; 1006 | }; 1007 | 1008 | mergeSchemas = function(childSchema, parentSchema) { 1009 | var schema = Ember.copy(parentSchema || {}); 1010 | 1011 | for (var name in childSchema) { 1012 | if (childSchema.hasOwnProperty(name)) { 1013 | if (schema.hasOwnProperty(name)) { 1014 | throw new Error("Schema item '" + name + "' is already defined"); 1015 | } 1016 | 1017 | schema[name] = childSchema[name]; 1018 | } 1019 | } 1020 | 1021 | return schema; 1022 | }; 1023 | 1024 | createSchemaProperties = function(schema) { 1025 | var properties = {}, schemaItem; 1026 | 1027 | for (var propertyName in schema) { 1028 | if (schema.hasOwnProperty(propertyName)) { 1029 | properties[propertyName] = schema[propertyName].property(); 1030 | } 1031 | } 1032 | 1033 | return properties; 1034 | }; 1035 | 1036 | 1037 | Ember.Resource.reopenClass({ 1038 | isEmberResource: true, 1039 | schema: {}, 1040 | 1041 | baseClass: function() { 1042 | if (this === Ember.Resource) { 1043 | return null; 1044 | } else { 1045 | return this.baseResourceClass || this; 1046 | } 1047 | }, 1048 | 1049 | subclassFor: function(options, data) { 1050 | return this; 1051 | }, 1052 | 1053 | // Create an instance of this resource. If `options` includes an 1054 | // `id`, first check the identity map and return the existing resource 1055 | // with that ID if found. 1056 | create: function(options, data) { 1057 | data = data || {}; 1058 | options = options || {}; 1059 | 1060 | var klass = this.subclassFor(options, data), idToRestore = options.id; 1061 | 1062 | if (klass === this) { 1063 | var instance; 1064 | 1065 | var id = data.id || options.id; 1066 | if (id && !options.skipIdentityMap && this.useIdentityMap) { 1067 | this.identityMap = this.identityMap || new Ember.Resource.IdentityMap(this.identityMapLimit, this.didEvictFromIdentityMap.bind(this)); 1068 | 1069 | id = id.toString(); 1070 | instance = this.identityMap.get(id); 1071 | 1072 | if (!instance) { 1073 | instance = this._super({ data: data }); 1074 | this.identityMap.put(id, instance); 1075 | } else { 1076 | instance.updateWithApiData(data); 1077 | // ignore incoming resourceState and id arguments 1078 | delete options.resourceState; 1079 | delete options.id; 1080 | } 1081 | } else { 1082 | instance = this._super({ data: data }); 1083 | } 1084 | 1085 | delete options.data; 1086 | 1087 | Ember.beginPropertyChanges(); 1088 | try { 1089 | var mixin = {}; 1090 | var hasMixin = false; 1091 | for (var name in options) { 1092 | if (options.hasOwnProperty(name)) { 1093 | if (this.schema[name]) { 1094 | instance.set(name, options[name]); 1095 | } else { 1096 | mixin[name] = options[name]; 1097 | hasMixin = true; 1098 | } 1099 | } 1100 | } 1101 | if (hasMixin) { 1102 | instance.reopen(mixin); 1103 | } 1104 | } catch (ex) { 1105 | Ember.Resource.logger && typeof Ember.Resource.logger.error === "function" && Ember.Resource.logger.error(ex); 1106 | } finally { 1107 | Ember.endPropertyChanges(); 1108 | } 1109 | 1110 | options.id = idToRestore; 1111 | return instance; 1112 | } else { 1113 | return klass.create(options, data); 1114 | } 1115 | }, 1116 | 1117 | // Parse JSON -- likely returned from an AJAX call -- into the 1118 | // properties for an instance of this resource. Override this method 1119 | // to produce different parsing behavior. 1120 | parse: function(json) { 1121 | return json; 1122 | }, 1123 | 1124 | // Define a resource class. 1125 | // 1126 | // Parameters: 1127 | // 1128 | // * `schema` -- the properties schema for the resource class 1129 | // * `url` -- either a function that returns the URL for syncing 1130 | // resources or a string. If the latter, a string of the form 1131 | // "/widgets" is turned into a function that returns "/widgets" if 1132 | // the Widget's ID is not present and "/widgets/{id}" if it is. 1133 | // * `parse` -- the function used to parse JSON returned from AJAX 1134 | // calls into the resource properties. By default, simply returns 1135 | // the JSON. 1136 | define: function(options) { 1137 | options = options || {}; 1138 | var schema = expandSchema(options.schema); 1139 | schema = mergeSchemas(schema, this.schema); 1140 | 1141 | var klass = this.extend(createSchemaProperties(schema), Ember.Resource.RemoteExpiry); 1142 | 1143 | var classOptions = { 1144 | schema: schema 1145 | }; 1146 | 1147 | if (this !== Ember.Resource) { 1148 | classOptions.baseResourceClass = this.baseClass() || this; 1149 | } 1150 | 1151 | if (options.url) { 1152 | classOptions.url = options.url; 1153 | } 1154 | 1155 | if (options.parse) { 1156 | classOptions.parse = options.parse; 1157 | } 1158 | 1159 | if (options.identityMapLimit) { 1160 | classOptions.identityMapLimit = options.identityMapLimit; 1161 | } 1162 | 1163 | if(typeof(options.useIdentityMap) !== "undefined") { 1164 | classOptions.useIdentityMap = options.useIdentityMap; 1165 | } else { 1166 | classOptions.useIdentityMap = true; 1167 | } 1168 | 1169 | if (options.sideloads) { 1170 | classOptions.sideloads = options.sideloads; 1171 | } 1172 | 1173 | klass.reopenClass(classOptions); 1174 | 1175 | return klass; 1176 | }, 1177 | 1178 | extendSchema: function(schema) { 1179 | schema = expandSchema(schema); 1180 | this.schema = mergeSchemas(schema, this.schema); 1181 | this.reopen(createSchemaProperties(schema)); 1182 | return this; 1183 | }, 1184 | 1185 | resourceURL: function(instance) { 1186 | if (Ember.typeOf(this.url) == 'function') { 1187 | return this.url(instance); 1188 | } else if (this.url) { 1189 | if (instance) { 1190 | var id = getPath(instance, 'id'); 1191 | if (id == null || id === '') { 1192 | return this.url; 1193 | } 1194 | 1195 | if (Ember.typeOf(id) !== 'number' || id > 0) { 1196 | return this.url + '/' + id; 1197 | } 1198 | } else { 1199 | return this.url; 1200 | } 1201 | } 1202 | }, 1203 | 1204 | idFromURL: function(url) { 1205 | var regex; 1206 | if (!this.schema.id) return; 1207 | 1208 | if (this.schema.id.get('type') === Number) { 1209 | regex = /\/(\d+)(\.\w+)?$/; 1210 | } else { 1211 | regex = /\/([^\/\.]+)(\.\w+)?$/; 1212 | } 1213 | 1214 | var match = (url || '').match(regex); 1215 | if (match) { 1216 | return match[1]; 1217 | } 1218 | }, 1219 | 1220 | // accepts a single id or an array of ids 1221 | findAndExpire: function(ids) { 1222 | var cache = this.identityMap && this.identityMap.cache; 1223 | var cacheRecord; 1224 | 1225 | if (ids == null || !cache) return; 1226 | 1227 | ids = Em.isArray(ids) ? ids : [ids]; 1228 | 1229 | for (var i=0; i 7 | * See README.md for details. 8 | * 9 | * Illustration of the design: 10 | * 11 | * entry entry entry entry 12 | * ______ ______ ______ ______ 13 | * | head |.newer => | |.newer => | |.newer => | tail | 14 | * | A | | B | | C | | D | 15 | * |______| <= older.|______| <= older.|______| <= older.|______| 16 | * 17 | * removed <-- <-- <-- <-- <-- <-- <-- <-- <-- <-- <-- added 18 | */ 19 | function LRUCache (limit) { 20 | // Current size of the cache. (Read-only). 21 | this.size = 0; 22 | // Maximum number of items this cache can hold. 23 | this.limit = limit; 24 | this._keymap = {}; 25 | } 26 | 27 | /** 28 | * Put into the cache associated with . Returns the entry which was 29 | * removed to make room for the new entry. Otherwise undefined is returned 30 | * (i.e. if there was enough room already). 31 | */ 32 | LRUCache.prototype.put = function(key, value) { 33 | var entry = {key:key, value:value}; 34 | // Note: No protection agains replacing, and thus orphan entries. By design. 35 | this._keymap[key] = entry; 36 | if (this.tail) { 37 | // link previous tail to the new tail (entry) 38 | this.tail.newer = entry; 39 | entry.older = this.tail; 40 | } else { 41 | // we're first in -- yay 42 | this.head = entry; 43 | } 44 | // add new entry to the end of the linked list -- it's now the freshest entry. 45 | this.tail = entry; 46 | if (this.size === this.limit) { 47 | // we hit the limit -- remove the head 48 | return this.shift(); 49 | } else { 50 | // increase the size counter 51 | this.size++; 52 | } 53 | }; 54 | 55 | /** 56 | * Purge the least recently used (oldest) entry from the cache. Returns the 57 | * removed entry or undefined if the cache was empty. 58 | * 59 | * If you need to perform any form of finalization of purged items, this is a 60 | * good place to do it. Simply override/replace this function: 61 | * 62 | * var c = new LRUCache(123); 63 | * c.shift = function() { 64 | * var entry = LRUCache.prototype.shift.call(this); 65 | * doSomethingWith(entry); 66 | * return entry; 67 | * } 68 | */ 69 | LRUCache.prototype.shift = function() { 70 | // todo: handle special case when limit == 1 71 | var entry = this.head; 72 | if (entry) { 73 | if (this.head.newer) { 74 | this.head = this.head.newer; 75 | this.head.older = undefined; 76 | } else { 77 | this.head = undefined; 78 | } 79 | // Remove last strong reference to and remove links from the purged 80 | // entry being returned: 81 | entry.newer = entry.older = undefined; 82 | // delete is slow, but we need to do this to avoid uncontrollable growth: 83 | delete this._keymap[entry.key]; 84 | } 85 | return entry; 86 | }; 87 | 88 | /** 89 | * Get and register recent use of . Returns the value associated with 90 | * or undefined if not in cache. 91 | */ 92 | LRUCache.prototype.get = function(key, returnEntry) { 93 | // First, find our cache entry 94 | var entry = this._keymap[key]; 95 | if (entry === undefined) return; // Not cached. Sorry. 96 | // As was found in the cache, register it as being requested recently 97 | if (entry === this.tail) { 98 | // Already the most recenlty used entry, so no need to update the list 99 | return entry.value; 100 | } 101 | // HEAD--------------TAIL 102 | // <.older .newer> 103 | // <--- add direction -- 104 | // A B C E 105 | if (entry.newer) { 106 | if (entry === this.head) 107 | this.head = entry.newer; 108 | entry.newer.older = entry.older; // C <-- E. 109 | } 110 | if (entry.older) 111 | entry.older.newer = entry.newer; // C. --> E 112 | entry.newer = undefined; // D --x 113 | entry.older = this.tail; // D. --> E 114 | if (this.tail) 115 | this.tail.newer = entry; // E. <-- D 116 | this.tail = entry; 117 | return returnEntry ? entry : entry.value; 118 | }; 119 | 120 | // ---------------------------------------------------------------------------- 121 | // Following code is optional and can be removed without breaking the core 122 | // functionality. 123 | 124 | /** 125 | * Check if is in the cache without registering recent use. Feasible if 126 | * you do not want to chage the state of the cache, but only "peek" at it. 127 | * Returns the entry associated with if found, or undefined if not found. 128 | */ 129 | LRUCache.prototype.find = function(key) { 130 | return this._keymap[key]; 131 | }; 132 | 133 | /** 134 | * Update the value of entry with . Returns the old value, or undefined if 135 | * entry was not in the cache. 136 | */ 137 | LRUCache.prototype.set = function(key, value) { 138 | var oldvalue, entry = this.get(key, true); 139 | if (entry) { 140 | oldvalue = entry.value; 141 | entry.value = value; 142 | } else { 143 | oldvalue = this.put(key, value); 144 | if (oldvalue) oldvalue = oldvalue.value; 145 | } 146 | return oldvalue; 147 | }; 148 | 149 | /** 150 | * Remove entry from cache and return its value. Returns undefined if not 151 | * found. 152 | */ 153 | LRUCache.prototype.remove = function(key) { 154 | var entry = this._keymap[key]; 155 | if (!entry) return; 156 | delete this._keymap[entry.key]; // need to do delete unfortunately 157 | if (entry.newer && entry.older) { 158 | // relink the older entry with the newer entry 159 | entry.older.newer = entry.newer; 160 | entry.newer.older = entry.older; 161 | } else if (entry.newer) { 162 | // remove the link to us 163 | entry.newer.older = undefined; 164 | // link the newer entry to head 165 | this.head = entry.newer; 166 | } else if (entry.older) { 167 | // remove the link to us 168 | entry.older.newer = undefined; 169 | // link the newer entry to head 170 | this.tail = entry.older; 171 | } else {// if(entry.older === undefined && entry.newer === undefined) { 172 | this.head = this.tail = undefined; 173 | } 174 | 175 | this.size--; 176 | return entry.value; 177 | }; 178 | 179 | /** Removes all entries */ 180 | LRUCache.prototype.removeAll = function() { 181 | // This should be safe, as we never expose strong refrences to the outside 182 | this.head = this.tail = undefined; 183 | this.size = 0; 184 | this._keymap = {}; 185 | }; 186 | 187 | /** 188 | * Return an array containing all keys of entries stored in the cache object, in 189 | * arbitrary order. 190 | */ 191 | if (typeof Object.keys === 'function') { 192 | LRUCache.prototype.keys = function() { return Object.keys(this._keymap); }; 193 | } else { 194 | LRUCache.prototype.keys = function() { 195 | var keys = []; 196 | for (var k in this._keymap) keys.push(k); 197 | return keys; 198 | }; 199 | } 200 | 201 | /** 202 | * Call `fun` for each entry. Starting with the newest entry if `desc` is a true 203 | * value, otherwise starts with the oldest (head) enrty and moves towards the 204 | * tail. 205 | * 206 | * `fun` is called with 3 arguments in the context `context`: 207 | * `fun.call(context, Object key, Object value, LRUCache self)` 208 | */ 209 | LRUCache.prototype.forEach = function(fun, context, desc) { 210 | var entry; 211 | if (context === true) { desc = true; context = undefined; } 212 | else if (typeof context !== 'object') context = this; 213 | if (desc) { 214 | entry = this.tail; 215 | while (entry) { 216 | fun.call(context, entry.key, entry.value, this); 217 | entry = entry.older; 218 | } 219 | } else { 220 | entry = this.head; 221 | while (entry) { 222 | fun.call(context, entry.key, entry.value, this); 223 | entry = entry.newer; 224 | } 225 | } 226 | }; 227 | 228 | /** Returns a JSON (array) representation */ 229 | LRUCache.prototype.toJSON = function() { 230 | var s = [], entry = this.head; 231 | while (entry) { 232 | s.push({key:entry.key.toJSON(), value:entry.value.toJSON()}); 233 | entry = entry.newer; 234 | } 235 | return s; 236 | }; 237 | 238 | /** Returns a String representation */ 239 | LRUCache.prototype.toString = function() { 240 | var s = '', entry = this.head; 241 | while (entry) { 242 | s += String(entry.key)+':'+entry.value; 243 | entry = entry.newer; 244 | if (entry) 245 | s += ' < '; 246 | } 247 | return s; 248 | }; 249 | 250 | // Export ourselves 251 | if (typeof this === 'object') this.LRUCache = LRUCache; 252 | --------------------------------------------------------------------------------