diff --git a/.travis.yml b/.travis.yml index d048991..c7a4ea5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,6 +14,7 @@ env: - EMBER_TRY_SCENARIO=ember-data-2.0 - EMBER_TRY_SCENARIO=ember-data-2.1 - EMBER_TRY_SCENARIO=ember-data-2.2 + - EMBER_TRY_SCENARIO=ember-data-2.3 - EMBER_TRY_SCENARIO=ember-release - EMBER_TRY_SCENARIO=ember-beta - EMBER_TRY_SCENARIO=ember-canary diff --git a/README.md b/README.md index 54622dd..f671806 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -# Ember Pouch - -[![Build Status](https://travis-ci.org/nolanlawson/ember-pouch.svg)](https://travis-ci.org/nolanlawson/ember-pouch) +# Ember Pouch [![Build Status](https://travis-ci.org/nolanlawson/ember-pouch.svg)](https://travis-ci.org/nolanlawson/ember-pouch) [**Changelog**](#changelog) @@ -69,7 +67,7 @@ var db = new PouchDB('local_pouch'); db.sync(remote, { live: true, // do a live, ongoing sync - retry: true // retry if the conection is lost + retry: true // retry if the connection is lost }); export default Adapter.extend({ @@ -87,37 +85,102 @@ PouchDB.debug.enable('*'); See the [PouchDB sync API](http://pouchdb.com/api.html#sync) for full usage instructions. -## Attachments +## EmberPouch Blueprints -`Ember-Pouch` provides an `attachment` transform for your models, which makes working with attachments is as simple as working with any other field. +### Model -Add a `DS.attr('attachment')` field to your model: +In order to create a model run the following command from the command line: -```js -// myapp/models/photo-album.js -export default DS.Model.extend({ - photos: DS.attr('attachment'); -}); +``` +ember g pouch-model ``` -Here, instances of `PhotoAlbum` have a `photos` field, which is an array of plain `Ember.Object`s, which have a `.name` and `.content_type`. Non-stubbed attachment also have a `.data` field; and stubbed attachments have a `.stub` instead. -```handlebars - +Replace `` with the name of your model and the file will automatically be generated for you. + +### Adapter + +You can now create an adapter using ember-cli's blueprint functionality. Once you've installed `ember-pouch` into your ember-cli app you can run the following command to automatically generate an application adapter. + +``` +ember g pouch-adapter application ``` -Attach new files by adding an `Ember.Object` with a `.name`, `.content_type` and `.data` to array of attachments. +Now you can store your localDb and remoteDb names in your ember-cli's config. Just add the following keys to the `ENV` object: + +```javascript +ENV.emberPouch.localDb = 'test'; +ENV.emberPouch.remoteDb = 'http://localhost:5984/my_couch'; +``` + +## Relationships + +EmberPouch supports both `hasMany` and `belongsTo` relationships. + +### Saving + +When saving a `hasMany` - `belongsTo` relationship, both sides of the relationship (the child and the parent) must be saved. Note that the parent needs to have been saved at least once prior to adding children to it. + +```javascript +// app/routes/post/index.js +import Ember from 'ember'; + +export default Ember.Route.extend({ + model(params){ + //We are getting a post that already exists + return this.store.findRecord('post', params.post_id); + }, + + actions:{ + addComment(comment, author){ + //Create the comment + const comment = this.store.createRecord('comment',{ + comment: comment, + author: author + }); + //Get our post + const post = this.controller.get('model'); + //Add our comment to our existing post + post.get('comments').pushObject(comment); + //Save the child then the parent + comment.save().then(() => post.save()); + } + } +}); + +``` + +### Removing + +When removing a `hasMany` - `belongsTo` relationship, the children must be removed prior to the parent being removed. + +```javascript +// app/routes/posts/admin/index.js +import Ember from 'ember'; + +export default Ember.Route.extend({ + model(){ + //We are getting all posts for some sort of list + return this.store.findAll('post'); + }, + + actions:{ + deletePost(post){ + //collect the promises for deletion + let deletedComments = []; + //get and destroy the posts comments + post.get('comments').then((comments) => { + comments.map((comment) => { + deletedComments.push(comment.destroyRecord()); + }); + }); + //Wait for comments to be destroyed then destroy the post + Ember.RSVP.all(deletedComments).then(() => { + post.destroyRecord(); + }); + } + } +}); -```js -// somewhere in your controller/component: -myAlbum.get('photos').addObject(Ember.Object.create({ - 'name': 'kitten.jpg', - 'content_type': 'image/jpg', - 'data': btoa('hello world') // base64-encoded `String`, or a DOM `Blob`, or a `File` -})); ``` ## Sample app @@ -137,6 +200,24 @@ From day one, CouchDB and its protocol have been designed to be always **A**vail To learn more about how CouchDB sync works, check out [the PouchDB guide to replication](http://pouchdb.com/guides/replication.html). +### Sync and the ember-data store + +Out of the box, ember-pouch includes a PouchDB [change listener](http://pouchdb.com/guides/changes.html) that automatically updates any records your app has loaded when they change due to a sync. It also unloads records that are removed due to a sync. + +However, ember-pouch does not automatically load new records that arrive during a sync. The records are saved in the local database, but **ember-data is not told to load them into memory**. Automatically loading every new record works well with a small number of records and a limited number of models. As an app grows, automatically loading every record will negatively impact app responsiveness during syncs (especially the first sync). To avoid puzzling slowdowns, ember-pouch only automatically reloads records you have already used ember-data to load. + +If you have a model or two that you know will always have a small number of records, you can tell ember-data to automatically load them into memory as they arrive. Your PouchAdapter subclass has a method `unloadedDocumentChanged`, which is called when a document is received during sync that has not been loaded into the ember-data store. In your subclass, you can implement the following to load it automatically: + +```js + unloadedDocumentChanged: function(obj) { + let store = this.get('store'); + let recordTypeName = this.getRecordTypeName(store.modelFor(obj.type)); + this.get('db').rel.find(recordTypeName, obj.id).then(function(doc) { + store.pushPayload(recordTypeName, doc); + }); + }, +``` + ### Plugins With PouchDB, you also get access to a whole host of [PouchDB plugins](http://pouchdb.com/external.html). @@ -216,6 +297,62 @@ The value for `documentType` is the camelCase version of the primary model name. For best results, only create/update records using the full model definition. Treat the others as read-only. +## Multiple databases for the same model + +In some cases it might diserable (security related, where you want a given user to only have some informations stored on his computer) to have multiple databases for the same model of data. + +`Ember-Pouch` allows you to dynamically change the database a model is using by calling the function `changeDb` on the adapter. + +```javascript +function changeProjectDatabase(dbName, dbUser, dbPassword) { + // CouchDB is serving at http://localhost:5455 + let remote = new PouchDB('http://localhost:5455/' + dbName); + // here we are using pouchdb-authentication for credential supports + remote.login( dbUser, dbPassword).then( + function (user) { + let db = new PouchDB(dbName) + db.sync(remote, {live:true, retry:true}) + // grab the adapter, it can be any ember-pouch adapter. + let adapter = this.store.adapterFor('project'); + // this is where we told the adapter to change the current database. + adapter.changeDb(db); + } + ) +} +``` + +### Attachments + +`Ember-Pouch` provides an `attachment` transform for your models, which makes working with attachments is as simple as working with any other field. + +Add a `DS.attr('attachment')` field to your model: + +```js +// myapp/models/photo-album.js +export default DS.Model.extend({ + photos: DS.attr('attachment'); +}); +``` + +Here, instances of `PhotoAlbum` have a `photos` field, which is an array of plain `Ember.Object`s, which have a `.name` and `.content_type`. Non-stubbed attachment also have a `.data` field; and stubbed attachments have a `.stub` instead. +```handlebars + +``` + +Attach new files by adding an `Ember.Object` with a `.name`, `.content_type` and `.data` to array of attachments. + +```js +// somewhere in your controller/component: +myAlbum.get('photos').addObject(Ember.Object.create({ + 'name': 'kitten.jpg', + 'content_type': 'image/jpg', + 'data': btoa('hello world') // base64-encoded `String`, or a DOM `Blob`, or a `File` +})); + ## Installation * `git clone` this repository @@ -246,6 +383,13 @@ And of course thanks to all our wonderful contributors, [here](https://github.co ## Changelog +* **3.1.1** + - Bugfix for hasMany relations by [@backspace](https://github.com/backspace) ([#111](https://github.com/nolanlawson/ember-pouch/pull/111)). +* **3.1.0** + - Database can now be dynamically switched on the adapter ([#89](https://github.com/nolanlawson/ember-pouch/pull/89)). Thanks to [@olivierchatry](https://github.com/olivierchatry) for this! + - Various bugfixes by [@backspace](https://github.com/backspace), [@jkleinsc](https://github.com/jkleinsc), [@rsutphin](https://github.com/rsutphin), [@mattmarcum](https://github.com/mattmarcum), [@broerse](https://github.com/broerse), and [@olivierchatry](https://github.com/olivierchatry). See [the full commit log](https://github.com/nolanlawson/ember-pouch/compare/7c216311ffacd2f08b57df4fe34d49f4e7c373f1...v3.1.0) for details. Thank you! +* **3.0.1** + - Add blueprints for model and adapter (see above for details). Thanks [@mattmarcum](https://github.com/mattmarcum) ([#101](https://github.com/nolanlawson/ember-pouch/issues/101), [#102](https://github.com/nolanlawson/ember-pouch/issues/102)) and [@backspace](https://github.com/backspace) ([#103](https://github.com/nolanlawson/ember-pouch/issues/103)). * **3.0.0** - Update for compatibility with Ember & Ember-Data 2.0+. The adapter now supports Ember & Ember-Data 1.13.x and 2.x only. * **2.0.3** diff --git a/addon/adapters/pouch.js b/addon/adapters/pouch.js index 281157f..cff8735 100644 --- a/addon/adapters/pouch.js +++ b/addon/adapters/pouch.js @@ -25,15 +25,35 @@ export default DS.RESTAdapter.extend({ // reloading redundant. shouldReloadRecord: function () { return false; }, shouldBackgroundReloadRecord: function () { return false; }, - - _startChangesToStoreListener: on('init', function () { - this.changes = this.get('db').changes({ - since: 'now', - live: true, - returnDocs: false - }).on('change', bind(this, 'onChange')); + _onInit : on('init', function() { + this._startChangesToStoreListener(); }), + _startChangesToStoreListener: function () { + var db = this.get('db'); + if (db) { + this.changes = db.changes({ + since: 'now', + live: true, + returnDocs: false + }).on('change', bind(this, 'onChange')); + } + }, + changeDb: function(db) { + if (this.changes) { + this.changes.cancel(); + } + var store = this.store; + var schema = this._schema || []; + + for (var i = 0, len = schema.length; i < len; i++) { + store.unloadAll(schema[i].singular); + } + + this._schema = null; + this.set('db', db); + this._startChangesToStoreListener(); + }, onChange: function (change) { // If relational_pouch isn't initialized yet, there can't be any records // in the store to update. @@ -56,6 +76,7 @@ export default DS.RESTAdapter.extend({ var recordInStore = store.peekRecord(obj.type, obj.id); if (!recordInStore) { // The record hasn't been loaded into the store; no need to reload its data. + this.unloadedDocumentChanged(obj); return; } if (!recordInStore.get('isLoaded') || recordInStore.get('hasDirtyAttributes')) { @@ -72,6 +93,19 @@ export default DS.RESTAdapter.extend({ } }, + unloadedDocumentChanged: function(/* obj */) { + /* + * For performance purposes, we don't load records into the store that haven't previously been loaded. + * If you want to change this, subclass this method, and push the data into the store. e.g. + * + * let store = this.get('store'); + * let recordTypeName = this.getRecordTypeName(store.modelFor(obj.type)); + * this.get('db').rel.find(recordTypeName, obj.id).then(function(doc){ + * store.pushPayload(recordTypeName, doc); + * }); + */ + }, + willDestroy: function() { if (this.changes) { this.changes.cancel(); diff --git a/addon/serializers/pouch.js b/addon/serializers/pouch.js index 6a892ae..ec5fdad 100644 --- a/addon/serializers/pouch.js +++ b/addon/serializers/pouch.js @@ -1,3 +1,19 @@ import DS from 'ember-data'; -export default DS.RESTSerializer.extend({}); \ No newline at end of file +export default DS.RESTSerializer.extend({ + _shouldSerializeHasMany: function() { + return true; + }, + + // This fixes a failure in Ember Data 1.13 where an empty hasMany + // was saving as undefined rather than []. + serializeHasMany(snapshot, json, relationship) { + this._super.apply(this, arguments); + + const key = relationship.key; + + if (!json[key]) { + json[key] = []; + } + } +}); diff --git a/blueprints/.jshintrc b/blueprints/.jshintrc new file mode 100644 index 0000000..33f4f6f --- /dev/null +++ b/blueprints/.jshintrc @@ -0,0 +1,6 @@ +{ + "predef": [ + "console" + ], + "strict": false +} diff --git a/blueprints/pouch-adapter/files/__root__/adapters/__name__.js b/blueprints/pouch-adapter/files/__root__/adapters/__name__.js new file mode 100644 index 0000000..e468366 --- /dev/null +++ b/blueprints/pouch-adapter/files/__root__/adapters/__name__.js @@ -0,0 +1,32 @@ +import { Adapter } from 'ember-pouch'; +import PouchDB from 'pouchdb'; +import config from '<%= dasherizedPackageName %>/config/environment'; +import Ember from 'ember'; + +const { assert, isEmpty } = Ember; + +function createDb() { + let localDb = config.emberPouch.localDb; + + assert('emberPouch.localDb must be set', !isEmpty(localDb)); + + let db = new PouchDB(localDb); + + if (config.emberPouch.remoteDb) { + let remoteDb = new PouchDB(config.emberPouch.remoteDb); + + db.sync(remoteDb, { + live: true, + retry: true + }); + } + + return db; +} + +export default Adapter.extend({ + init() { + this._super(...arguments); + this.set('db', createDb()); + } +}); diff --git a/blueprints/pouch-adapter/index.js b/blueprints/pouch-adapter/index.js new file mode 100644 index 0000000..41a516d --- /dev/null +++ b/blueprints/pouch-adapter/index.js @@ -0,0 +1,14 @@ +module.exports = { + description: '' + + // locals: function(options) { + // // Return custom template variables here. + // return { + // foo: options.entity.options.foo + // }; + // } + + // afterInstall: function(options) { + // // Perform extra work here. + // } +}; diff --git a/blueprints/pouch-model/files/__root__/models/__name__.js b/blueprints/pouch-model/files/__root__/models/__name__.js new file mode 100644 index 0000000..04750b6 --- /dev/null +++ b/blueprints/pouch-model/files/__root__/models/__name__.js @@ -0,0 +1,12 @@ +import Model from 'ember-pouch/model'; +import DS from 'ember-data'; + +const { + attr, + hasMany, + belongsTo +} = DS; + +export default Model.extend({ + <%= attrs %> +}); diff --git a/blueprints/pouch-model/index.js b/blueprints/pouch-model/index.js new file mode 100644 index 0000000..3a7d2cd --- /dev/null +++ b/blueprints/pouch-model/index.js @@ -0,0 +1,3 @@ +var EmberCliModelBlueprint = require('ember-cli/blueprints/model'); + +module.exports = EmberCliModelBlueprint; diff --git a/bower.json b/bower.json index f56480f..4da0589 100644 --- a/bower.json +++ b/bower.json @@ -23,17 +23,17 @@ ], "dependencies": { "ember": "~1.13.0 || 2.x", - "ember-cli-shims": "0.0.6", + "ember-cli-shims": "0.1.0", "ember-cli-test-loader": "0.2.1", "ember-data": "~1.13.0 || 2.x", "ember-load-initializers": "0.1.7", "ember-qunit": "0.4.16", "ember-qunit-notifications": "0.1.0", "ember-resolver": "~0.1.20", - "jquery": "^1.11.3", + "jquery": "1.11.3", "loader.js": "ember-cli/loader.js#3.4.0", "qunit": "~1.20.0", "pouchdb": "^3.5.0", "relational-pouch": "^1.3.2" } -} \ No newline at end of file +} diff --git a/config/ember-try.js b/config/ember-try.js index 6393d72..ff666d3 100644 --- a/config/ember-try.js +++ b/config/ember-try.js @@ -4,7 +4,8 @@ module.exports = { name: 'ember-data-1.13', dependencies: { 'ember': '1.13.11', - 'ember-data': '1.13.15' + 'ember-data': '1.13.15', + 'ember-cli-shims': '0.0.6' }, resolutions: { 'ember': '1.13.11' @@ -14,7 +15,8 @@ module.exports = { name: 'ember-data-2.0', dependencies: { 'ember': '2.0.2', - 'ember-data': '2.0.1' + 'ember-data': '2.0.1', + 'ember-cli-shims': '0.0.6' }, resolutions: { 'ember': '2.0.2' @@ -24,7 +26,8 @@ module.exports = { name: 'ember-data-2.1', dependencies: { 'ember': '2.1.1', - 'ember-data': '2.1.0' + 'ember-data': '2.1.0', + 'ember-cli-shims': '0.0.6' }, resolutions: { 'ember': '2.1.1' @@ -34,12 +37,23 @@ module.exports = { name: 'ember-data-2.2', dependencies: { 'ember': '2.2.0', - 'ember-data': '2.2.1' + 'ember-data': '2.2.1', + 'ember-cli-shims': '0.0.6' }, resolutions: { 'ember': '2.2.0' } }, + { + name: 'ember-data-2.3', + dependencies: { + 'ember': '2.2.1', + 'ember-data': '2.3.0', + }, + resolutions: { + 'ember': '2.2.1' + } + }, { name: 'ember-release', dependencies: { diff --git a/index.js b/index.js index fbb1ced..c56da1f 100644 --- a/index.js +++ b/index.js @@ -5,19 +5,21 @@ var VersionChecker = require('ember-cli-version-checker'); // We support Ember-Data 1.13.x and 2.x. Checking for this is complicated // because the effective version of ember-data is controlled by bower for -// 1.13.0-2.3.x and npm for 2.4.x. This gets as close as we can get using +// 1.13.0-2.2.x and npm for 2.3.x. This gets as close as we can get using // ember-cli-version-checker. function satisfactoryEmberDataVersion(addon) { var checker = new VersionChecker(addon), bowerEmberData = checker.for('ember-data', 'bower'), npmEmberData = checker.for('ember-data', 'npm'); - return npmEmberData.isAbove('2.3.99') || bowerEmberData.isAbove('1.12.99'); + return npmEmberData.isAbove('2.2.99') || bowerEmberData.isAbove('1.12.99'); } module.exports = { name: 'ember-pouch', init: function () { + this._super.init && this._super.init.apply(this, arguments); + if (!satisfactoryEmberDataVersion(this)) { var error = new Error("ember-pouch requires ember-data 1.13.x or 2.x"); error.suppressStacktrace = true; diff --git a/package.json b/package.json index 24520d0..dd42981 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ember-pouch", - "version": "3.0.0", + "version": "3.2.1", "description": "PouchDB adapter for Ember Data", "directories": { "doc": "doc", @@ -53,7 +53,7 @@ }, "dependencies": { "ember-cli-babel": "^5.1.5", - "ember-cli-version-checker": "1.1.4" + "ember-cli-version-checker": "1.1.5" }, "ember-addon": { "configPath": "tests/dummy/config" diff --git a/tests/dummy/app/adapters/application.js b/tests/dummy/app/adapters/application.js index b087629..740da8a 100644 --- a/tests/dummy/app/adapters/application.js +++ b/tests/dummy/app/adapters/application.js @@ -1,8 +1,27 @@ import { Adapter } from 'ember-pouch/index'; import PouchDB from 'pouchdb'; +import config from 'dummy/config/environment'; +import Ember from 'ember'; + +const { assert, isEmpty } = Ember; function createDb() { - return new PouchDB('ember-pouch-test'); + let localDb = config.emberpouch.localDb; + + assert('emberpouch.localDb must be set', !isEmpty(localDb)); + + let db = new PouchDB(localDb); + + if (config.emberpouch.remote) { + let remoteDb = new PouchDB(config.emberpouch.remoteDb); + + db.sync(remoteDb, { + live: true, + retry: true + }); + } + + return db; } export default Adapter.extend({ diff --git a/tests/dummy/app/adapters/taco-salad.js b/tests/dummy/app/adapters/taco-salad.js new file mode 100644 index 0000000..9ec86b7 --- /dev/null +++ b/tests/dummy/app/adapters/taco-salad.js @@ -0,0 +1,39 @@ +import { Adapter } from 'ember-pouch/index'; +import PouchDB from 'pouchdb'; +import config from 'dummy/config/environment'; +import Ember from 'ember'; + +const { assert, isEmpty } = Ember; + +function createDb() { + let localDb = config.emberpouch.localDb; + + assert('emberpouch.localDb must be set', !isEmpty(localDb)); + + let db = new PouchDB(localDb); + + if (config.emberpouch.remote) { + let remoteDb = new PouchDB(config.emberpouch.remoteDb); + + db.sync(remoteDb, { + live: true, + retry: true + }); + } + + return db; +} + +export default Adapter.extend({ + init() { + this._super(...arguments); + this.set('db', createDb()); + }, + unloadedDocumentChanged(obj) { + let store = this.get('store'); + let recordTypeName = this.getRecordTypeName(store.modelFor(obj.type)); + this.get('db').rel.find(recordTypeName, obj.id).then(function(doc){ + store.pushPayload(recordTypeName, doc); + }); + } +}); diff --git a/tests/dummy/app/models/food-item.js b/tests/dummy/app/models/food-item.js index 5a441cb..702c0d3 100644 --- a/tests/dummy/app/models/food-item.js +++ b/tests/dummy/app/models/food-item.js @@ -5,5 +5,6 @@ import DS from 'ember-data'; export default DS.Model.extend({ rev: DS.attr('string'), - name: DS.attr('string') + name: DS.attr('string'), + soup: DS.belongsTo('taco-soup', { async: true }) }); diff --git a/tests/dummy/app/models/taco-salad.js b/tests/dummy/app/models/taco-salad.js new file mode 100644 index 0000000..8cc83c4 --- /dev/null +++ b/tests/dummy/app/models/taco-salad.js @@ -0,0 +1,8 @@ +import DS from 'ember-data'; + +export default DS.Model.extend({ + rev: DS.attr('string'), + + flavor: DS.attr('string'), + ingredients: DS.hasMany('food-item', { async: true }) +}); diff --git a/tests/dummy/app/serializers/application.js b/tests/dummy/app/serializers/application.js new file mode 100644 index 0000000..060aa04 --- /dev/null +++ b/tests/dummy/app/serializers/application.js @@ -0,0 +1,3 @@ +import { Serializer } from 'ember-pouch'; + +export default Serializer; diff --git a/tests/dummy/config/environment.js b/tests/dummy/config/environment.js index c59bcd5..f50bb9c 100644 --- a/tests/dummy/config/environment.js +++ b/tests/dummy/config/environment.js @@ -4,6 +4,7 @@ module.exports = function(environment) { var ENV = { modulePrefix: 'dummy', environment: environment, + emberpouch: { localDb: 'ember-pouch-test' }, baseURL: '/', locationType: 'auto', EmberENV: { @@ -16,7 +17,8 @@ module.exports = function(environment) { APP: { // Here you can pass flags/options to your application instance // when it is created - } + }, + }; if (environment === 'development') { @@ -37,10 +39,10 @@ module.exports = function(environment) { ENV.APP.LOG_VIEW_LOOKUPS = false; ENV.APP.rootElement = '#ember-testing'; + } if (environment === 'production') { - } return ENV; diff --git a/tests/helpers/module-for-acceptance.js b/tests/helpers/module-for-acceptance.js index ed23003..65c61ba 100644 --- a/tests/helpers/module-for-acceptance.js +++ b/tests/helpers/module-for-acceptance.js @@ -1,15 +1,44 @@ import { module } from 'qunit'; import startApp from '../helpers/start-app'; import destroyApp from '../helpers/destroy-app'; +import config from 'dummy/config/environment'; + +import Ember from 'ember'; +/* globals PouchDB */ export default function(name, options = {}) { module(name, { - beforeEach() { - this.application = startApp(); + beforeEach(assert) { + var done = assert.async(); - if (options.beforeEach) { - options.beforeEach.apply(this, arguments); - } + Ember.RSVP.Promise.resolve().then(() => { + return (new PouchDB(config.emberpouch.localDb)).destroy(); + }).then(() => { + this.application = startApp(); + + this.lookup = function (item) { + return this.application.__container__.lookup(item); + }; + + this.store = function store() { + return this.lookup('service:store'); + }; + + // At the container level, adapters are not singletons (ember-data + // manages them). To get the instance that the app is using, we have to + // go through the store. + this.adapter = function adapter() { + return this.store().adapterFor('taco-soup'); + }; + + this.db = function db() { + return this.adapter().get('db'); + }; + + if (options.beforeEach) { + options.beforeEach.apply(this, arguments); + } + }).finally(done); }, afterEach() { diff --git a/tests/integration/adapters/pouch-test.js b/tests/integration/adapters/pouch-basics-test.js similarity index 73% rename from tests/integration/adapters/pouch-test.js rename to tests/integration/adapters/pouch-basics-test.js index 4f0b3f1..b92973b 100644 --- a/tests/integration/adapters/pouch-test.js +++ b/tests/integration/adapters/pouch-basics-test.js @@ -1,70 +1,26 @@ -import { module, test } from 'qunit'; -import startApp from '../../helpers/start-app'; +import { test } from 'qunit'; +import moduleForIntegration from '../../helpers/module-for-acceptance'; import Ember from 'ember'; -/* globals PouchDB */ - -var App; /* * Tests basic CRUD behavior for an app using the ember-pouch adapter. */ -module('adapter:pouch [integration]', { - beforeEach: function (assert) { - var done = assert.async(); - - // TODO: do this in a way that doesn't require duplicating the name of the - // test database here and in dummy/app/adapters/application.js. Importing - // the adapter directly doesn't work because of what seems like a resolver - // issue. - (new PouchDB('ember-pouch-test')).destroy().then(() => { - App = startApp(); - var bootPromise; - Ember.run(() => { - if (App.boot) { - App.advanceReadiness(); - bootPromise = App.boot(); - } else { - bootPromise = Ember.RSVP.Promise.resolve(); - } - }); - return bootPromise; - }).then(() => { - done(); - }); - }, - - afterEach: function () { - Ember.run(App, 'destroy'); - } -}); - -function db() { - return adapter().get('db'); -} - -function adapter() { - // the default adapter in the dummy app is an ember-pouch adapter - return App.__container__.lookup('adapter:application'); -} - -function store() { - return App.__container__.lookup('service:store'); -} +moduleForIntegration('Integration | Adapter | Basic CRUD Ops'); test('can find all', function (assert) { assert.expect(3); var done = assert.async(); Ember.RSVP.Promise.resolve().then(() => { - return db().bulkDocs([ + return this.db().bulkDocs([ { _id: 'tacoSoup_2_A', data: { flavor: 'al pastor' } }, { _id: 'tacoSoup_2_B', data: { flavor: 'black bean' } }, { _id: 'burritoShake_2_X', data: { consistency: 'smooth' } } ]); }).then(() => { - return store().findAll('taco-soup'); + return this.store().findAll('taco-soup'); }).then((found) => { assert.equal(found.get('length'), 2, 'should have found the two taco soup items only'); assert.deepEqual(found.mapBy('id'), ['A', 'B'], @@ -84,12 +40,12 @@ test('can find one', function (assert) { var done = assert.async(); Ember.RSVP.Promise.resolve().then(() => { - return db().bulkDocs([ + return this.db().bulkDocs([ { _id: 'tacoSoup_2_C', data: { flavor: 'al pastor' } }, { _id: 'tacoSoup_2_D', data: { flavor: 'black bean' } }, ]); }).then(() => { - return store().find('taco-soup', 'D'); + return this.store().find('taco-soup', 'D'); }).then((found) => { assert.equal(found.get('id'), 'D', 'should have found the requested item'); @@ -108,7 +64,7 @@ test('can find associated records', function (assert) { var done = assert.async(); Ember.RSVP.Promise.resolve().then(() => { - return db().bulkDocs([ + return this.db().bulkDocs([ { _id: 'tacoSoup_2_C', data: { flavor: 'al pastor', ingredients: ['X', 'Y'] } }, { _id: 'tacoSoup_2_D', data: { flavor: 'black bean', ingredients: ['Z'] } }, { _id: 'foodItem_2_X', data: { name: 'pineapple' }}, @@ -116,7 +72,7 @@ test('can find associated records', function (assert) { { _id: 'foodItem_2_Z', data: { name: 'black beans' }} ]); }).then(() => { - return store().find('taco-soup', 'C'); + return this.store().find('taco-soup', 'C'); }).then((found) => { assert.equal(found.get('id'), 'C', 'should have found the requested item'); @@ -139,14 +95,14 @@ test('create a new record', function (assert) { var done = assert.async(); Ember.RSVP.Promise.resolve().then(() => { - var newSoup = store().createRecord('taco-soup', { id: 'E', flavor: 'balsamic' }); + var newSoup = this.store().createRecord('taco-soup', { id: 'E', flavor: 'balsamic' }); return newSoup.save(); }).then(() => { - return db().get('tacoSoup_2_E'); + return this.db().get('tacoSoup_2_E'); }).then((newDoc) => { assert.equal(newDoc.data.flavor, 'balsamic', 'should have saved the attribute'); - var recordInStore = store().peekRecord('tacoSoup', 'E'); + var recordInStore = this.store().peekRecord('tacoSoup', 'E'); assert.equal(newDoc._rev, recordInStore.get('rev'), 'should have associated the ember-data record with the rev for the new record'); @@ -158,6 +114,36 @@ test('create a new record', function (assert) { }); }); +test('creating an associated record stores a reference to it in the parent', function (assert) { + assert.expect(1); + + var done = assert.async(); + Ember.RSVP.Promise.resolve().then(() => { + return this.db().bulkDocs([ + { _id: 'tacoSoup_2_C', data: { flavor: 'al pastor', ingredients: [] } } + ]); + }).then(() => { + return this.store().findRecord('taco-soup', 'C'); + }).then(tacoSoup => { + var newIngredient = this.store().createRecord('food-item', { + name: 'pineapple', + soup: tacoSoup + }); + + return newIngredient.save().then(() => tacoSoup.save()); + }).then(() => { + this.store().unloadAll(); + + return this.store().findRecord('taco-soup', 'C'); + }).then(tacoSoup => { + return tacoSoup.get('ingredients'); + }).then(foundIngredients => { + assert.deepEqual(foundIngredients.mapBy('name'), ['pineapple'], + 'should have fully loaded the associated items'); + done(); + }); +}); + // This test fails due to a bug in ember data // (https://github.com/emberjs/data/issues/3736) // starting with ED v2.0.0-beta.1. It works again with ED v2.1.0. @@ -167,21 +153,21 @@ if (!DS.VERSION.match(/^2\.0/)) { var done = assert.async(); Ember.RSVP.Promise.resolve().then(() => { - return db().bulkDocs([ + return this.db().bulkDocs([ { _id: 'tacoSoup_2_C', data: { flavor: 'al pastor' } }, { _id: 'tacoSoup_2_D', data: { flavor: 'black bean' } }, ]); }).then(() => { - return store().find('taco-soup', 'C'); + return this.store().find('taco-soup', 'C'); }).then((found) => { found.set('flavor', 'pork'); return found.save(); }).then(() => { - return db().get('tacoSoup_2_C'); + return this.db().get('tacoSoup_2_C'); }).then((updatedDoc) => { assert.equal(updatedDoc.data.flavor, 'pork', 'should have updated the attribute'); - var recordInStore = store().peekRecord('tacoSoup', 'C'); + var recordInStore = this.store().peekRecord('tacoSoup', 'C'); assert.equal(updatedDoc._rev, recordInStore.get('rev'), 'should have associated the ember-data record with the updated rev'); @@ -199,16 +185,16 @@ test('delete an existing record', function (assert) { var done = assert.async(); Ember.RSVP.Promise.resolve().then(() => { - return db().bulkDocs([ + return this.db().bulkDocs([ { _id: 'tacoSoup_2_C', data: { flavor: 'al pastor' } }, { _id: 'tacoSoup_2_D', data: { flavor: 'black bean' } }, ]); }).then(() => { - return store().find('taco-soup', 'C'); + return this.store().find('taco-soup', 'C'); }).then((found) => { return found.destroyRecord(); }).then(() => { - return db().get('tacoSoup_2_C'); + return this.db().get('tacoSoup_2_C'); }).then((doc) => { assert.ok(!doc, 'document should no longer exist'); }, (result) => { diff --git a/tests/integration/adapters/pouch-default-change-watcher-test.js b/tests/integration/adapters/pouch-default-change-watcher-test.js new file mode 100644 index 0000000..cf0285e --- /dev/null +++ b/tests/integration/adapters/pouch-default-change-watcher-test.js @@ -0,0 +1,196 @@ +import { test } from 'qunit'; +import moduleForIntegration from '../../helpers/module-for-acceptance'; + +import Ember from 'ember'; + +/* + * Tests for the default automatic change listener. + */ + +moduleForIntegration('Integration | Adapter | Default Change Watcher', { + beforeEach(assert) { + var done = assert.async(); + + Ember.RSVP.Promise.resolve().then(() => { + return this.db().bulkDocs([ + { _id: 'tacoSoup_2_A', data: { flavor: 'al pastor', ingredients: ['X', 'Y'] } }, + { _id: 'tacoSoup_2_B', data: { flavor: 'black bean', ingredients: ['Z'] } }, + { _id: 'foodItem_2_X', data: { name: 'pineapple' } }, + { _id: 'foodItem_2_Y', data: { name: 'pork loin' } }, + { _id: 'foodItem_2_Z', data: { name: 'black beans' } } + ]); + }).finally(done); + } +}); + +function promiseToRunLater(callback, timeout) { + return new Ember.RSVP.Promise((resolve) => { + Ember.run.later(() => { + callback(); + resolve(); + }, timeout); + }); +} + +test('a loaded instance automatically reflects directly-made database changes', function (assert) { + assert.expect(2); + var done = assert.async(); + + Ember.RSVP.resolve().then(() => { + return this.store().find('taco-soup', 'B'); + }).then((soupB) => { + assert.equal('black bean', soupB.get('flavor'), + 'the loaded instance should reflect the initial test data'); + + return this.db().get('tacoSoup_2_B'); + }).then((soupBRecord) => { + soupBRecord.data.flavor = 'carnitas'; + return this.db().put(soupBRecord); + }).then(() => { + return promiseToRunLater(() => { + var alreadyLoadedSoupB = this.store().peekRecord('taco-soup', 'B'); + assert.equal(alreadyLoadedSoupB.get('flavor'), 'carnitas', + 'the loaded instance should automatically reflect the change in the database'); + }, 15); + }).finally(done); +}); + +test('a record that is not loaded stays not loaded when it is changed', function (assert) { + assert.expect(2); + var done = assert.async(); + + Ember.RSVP.resolve().then(() => { + assert.equal(null, this.store().peekRecord('taco-soup', 'A'), + 'test setup: record should not be loaded already'); + + return this.db().get('tacoSoup_2_A'); + }).then((soupARecord) => { + soupARecord.data.flavor = 'barbacoa'; + return this.db().put(soupARecord); + }).then(() => { + return promiseToRunLater(() => { + assert.equal(null, this.store().peekRecord('taco-soup', 'A'), + 'the corresponding instance should still not be loaded'); + }, 15); + }).finally(done); +}); + +test('a new record is not automatically loaded', function (assert) { + assert.expect(2); + var done = assert.async(); + + Ember.RSVP.resolve().then(() => { + assert.equal(null, this.store().peekRecord('taco-soup', 'C'), + 'test setup: record should not be loaded already'); + + return this.db().put({ + _id: 'tacoSoup_2_C', data: { flavor: 'sofritas' } + }); + }).then(() => { + return promiseToRunLater(() => { + assert.equal(null, this.store().peekRecord('taco-soup', 'C'), + 'the corresponding instance should still not be loaded'); + }, 15); + }).finally(done); +}); + +test('a deleted record is automatically unloaded', function (assert) { + assert.expect(2); + var done = assert.async(); + + Ember.RSVP.resolve().then(() => { + return this.store().find('taco-soup', 'B'); + }).then((soupB) => { + assert.equal('black bean', soupB.get('flavor'), + 'the loaded instance should reflect the initial test data'); + + return this.db().get('tacoSoup_2_B'); + }).then((soupBRecord) => { + return this.db().remove(soupBRecord); + }).then(() => { + return promiseToRunLater(() => { + assert.equal(null, this.store().peekRecord('taco-soup', 'B'), + 'the corresponding instance should no longer be loaded'); + }, 15); + }).finally(done); +}); + +test('a change to a record with a non-relational-pouch ID does not cause an error', function (assert) { + assert.expect(0); + var done = assert.async(); + + Ember.RSVP.resolve().then(() => { + // do some op to cause relational-pouch to be initialized + return this.store().find('taco-soup', 'B'); + }).then(() => { + return this.db().put({ + _id: '_design/ingredient-use' + }); + }).finally(done); +}); + +test('a change to a record of an unknown type does not cause an error', function (assert) { + assert.expect(0); + var done = assert.async(); + + Ember.RSVP.resolve().then(() => { + // do some op to cause relational-pouch to be initialized + return this.store().find('taco-soup', 'B'); + }).then(() => { + return this.db().put({ + _id: 'burritoShake_2_X', data: { consistency: 'chunky' } + }); + }).finally(done); +}); + +moduleForIntegration('Integration | Adapter | With unloadedDocumentChanged implementation to load new docs into store', { + beforeEach(assert) { + var done = assert.async(); + this.adapter = function adapter() { + return this.store().adapterFor('taco-salad'); + }; + this.db = function db() { + return this.adapter().get('db'); + }; + + Ember.RSVP.Promise.resolve().then(() => { + return this.db().bulkDocs([ + { _id: 'tacoSalad_2_A', data: { flavor: 'al pastor', ingredients: ['X', 'Y'] } }, + { _id: 'tacoSalad_2_B', data: { flavor: 'black bean', ingredients: ['Z'] } }, + { _id: 'foodItem_2_X', data: { name: 'pineapple' } }, + { _id: 'foodItem_2_Y', data: { name: 'pork loin' } }, + { _id: 'foodItem_2_Z', data: { name: 'black beans' } } + ]); + }).finally(done); + } +}); + +test('a new record is automatically loaded', function (assert) { + assert.expect(4); + var done = assert.async(); + + Ember.RSVP.resolve().then(() => { + return this.store().find('taco-salad', 'B'); + }).then((soupB) => { + assert.equal('black bean', soupB.get('flavor'), + 'the loaded instance should reflect the initial test data'); + }).then(() => { + assert.equal(null, this.store().peekRecord('taco-salad', 'C'), + 'test setup: record should not be loaded already'); + + return this.db().put({ + _id: 'tacoSalad_2_C', data: { flavor: 'sofritas' } + }); + }).then(() => { + return promiseToRunLater(() => { + var alreadyLoadedSaladC = this.store().peekRecord('taco-salad', 'C'); + assert.ok(alreadyLoadedSaladC, + 'the corresponding instance should now be loaded'); + if (alreadyLoadedSaladC) { + assert.equal(alreadyLoadedSaladC.get('flavor'), 'sofritas', + 'the corresponding instance should now be loaded with the right data'); + } + }, 15); + }).finally(done); +}); +