From 07d3c774519adc3d9b46b9e2d39eb653da2ca211 Mon Sep 17 00:00:00 2001 From: Timothy Farrell Date: Wed, 4 Jul 2018 08:22:23 -0500 Subject: [PATCH] PouchORM becomes PouchType API changed and full test coverage. --- README.md | 5 + packages/pouchorm/README.md | 140 ------- packages/pouchorm/rollup.config.js | 21 - packages/pouchorm/spec/pouchorm.spec.js | 7 - packages/pouchorm/spec/support/jasmine.json | 7 - packages/pouchorm/src/plugin.js | 113 ------ packages/pouchorm/src/type.js | 73 ---- packages/pouchtype/README.md | 137 +++++++ packages/{pouchorm => pouchtype}/package.json | 10 +- .../spec/livearray.spec.js | 64 +--- packages/pouchtype/spec/typehandler.spec.js | 360 ++++++++++++++++++ .../spec/watcher.spec.js | 0 packages/{pouchorm => pouchtype}/src/index.js | 3 +- .../{pouchorm => pouchtype}/src/livearray.js | 30 +- packages/pouchtype/src/type.js | 132 +++++++ packages/{pouchorm => pouchtype}/src/utils.js | 13 +- .../{pouchorm => pouchtype}/src/watcher.js | 0 packages/pouchtype/test.json | 9 + 18 files changed, 686 insertions(+), 438 deletions(-) delete mode 100644 packages/pouchorm/README.md delete mode 100644 packages/pouchorm/rollup.config.js delete mode 100644 packages/pouchorm/spec/pouchorm.spec.js delete mode 100644 packages/pouchorm/spec/support/jasmine.json delete mode 100644 packages/pouchorm/src/plugin.js delete mode 100644 packages/pouchorm/src/type.js create mode 100644 packages/pouchtype/README.md rename packages/{pouchorm => pouchtype}/package.json (67%) rename packages/{pouchorm => pouchtype}/spec/livearray.spec.js (54%) create mode 100644 packages/pouchtype/spec/typehandler.spec.js rename packages/{pouchorm => pouchtype}/spec/watcher.spec.js (100%) rename packages/{pouchorm => pouchtype}/src/index.js (51%) rename packages/{pouchorm => pouchtype}/src/livearray.js (62%) create mode 100644 packages/pouchtype/src/type.js rename packages/{pouchorm => pouchtype}/src/utils.js (68%) rename packages/{pouchorm => pouchtype}/src/watcher.js (100%) create mode 100644 packages/pouchtype/test.json diff --git a/README.md b/README.md index de46a46..1a80cf6 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ A browser-based app for viewing photos. (WIP) A utility to expose an asynchronous API between a web worker and its parent. +## [PouchType](./packages/pouchtype/README.md) + +An type-based abstraction layer over PouchDB inspired by [Hood.ie](https://hood.ie/) and +[Django](https://djangoproject.com) + ## [Router](./packages/router/README.md) A slim and unopinionated hash router. diff --git a/packages/pouchorm/README.md b/packages/pouchorm/README.md deleted file mode 100644 index 5be06a5..0000000 --- a/packages/pouchorm/README.md +++ /dev/null @@ -1,140 +0,0 @@ -# PouchORM - -An ORM for PouchDB inspired by [Hood.ie](https://hood.ie/) and [Django](https://djangoproject.com) - -## Usage - -### Type Definition - -In a type definition class, specify a static `getUniqueID` method and optionally a static `validate` -method. - -```js -import { PouchDB } from 'pouchdb'; -import { TypeSpec } from 'PouchORM'; - -class FileSpec extends TypeSpec { - static getUniqueID(doc) { - return doc.digest.substr(0, 16); - } - - static validate(doc) { - const REQUIRED_PROPS = ['filename', 'size', 'digest']; - const missing_props = REQUIRED_PROPS.filter(prop => !doc.hasOwnProperty(prop)); - if (missing_props) { - throw new ValidationError('FileType missing required props: ', missing_props); - } - } -} - -export const FileType = PouchDB.registerType('File', FileSpec); -``` - -Document properties of a type are not directly defined but the type definition. Specific properties -can be enforced by the `validate` method. The following properties are illegal: - -* starting with an underscore (\_) - these are used by PouchDB internally -* starting with a double dollar-sign ($$) - these are reserved for future use by PouchORM. (Single - dollar-sign properties may be used for your implementation’s meta-data.) - -### Type Functions - -* `FileType.getOrCreate(obj)` - Try to save the passed object as a document. If it fails with a /409 - Conflict/, look up the existing one and return it (ignoring differences between passed object and - the found object) - - * /obj/ - an object containing a partial document including an `id` property - * _Returns_ a FileType instance - -* `FileType.find(idOrSelector, options)` - Find one or many records. Optionally return a - subscribeable that will fire events when the selection changes. - - * /idOrSelector/ - * if a string or number is passed, `find` will attempt to lookup the document with this as the - id. - * if an object is passed, it will be treated as a selector for a find query - * /options/ can have the following optional properties - * /index/ - specify a specific index to use with the passed selector - * /live/ - boolean (default /false/) return a subscribeable that will fire when the results of - the selector are updated. - * /…/ - other options will be passed to `db.changes()` when `live: true` - * _Returns_ - * if `options.live` is /false/, an array of FileType instances will be returned - * if `options.live` is /true/, a subscribeable will be returned. Subscription callbacks will be - called when the results of /idOrSelector/ changes - -* `FileType.index(index, fields)` - Create a PouchDB - [Index](https://pouchdb.com/api.html#create_index) that can be used by `FileType.find` to sort on - non-ID fields - - * /index/ - a string value that can optionally be provided to the options object of - `FileType.find` - * /fields/ - an array of field names for PouchDB to index on. - * _Returns_ void - -* `FileType.delete(id)` - Remove a document from the db - - * /id/ - the id string of the document to be removed - * _Returns_ void - -* `FileType.subscribe(callback)` - subscribe to all FileType changes - * _Returns_ - a function that unsubscribes the passed callback when called. - -### Type Properties - -* `FileType.db` - the PouchDB database used by `FileType` -* `FileType.name` - the type name passed to the `registerType` function -* `FileType.prefix` - the prefix used on all document ids stored in PouchDB -* `FileType.selector` - the PouchDB selector definition used by ‘subscribe’ and ‘find’ - -### Instance Methods - -Creating a new instance is standard Javascript for any new instance: - -```js -let note = new FileType({ - filename: 'note.txt', - type: 'text/plain' -}); -``` - -* _static_ `note.getUniqueID(doc)` - You _must override_ this function in your type definition to - provide the id to be used for this document. - - * /doc/ - an object representing the document. This could be an instance of the type or a bare - object with properties for this type. - * _Returns_ - a unique string value to be used as a document id - -* _static_ `note.validate()` - By default this does nothing. Optionally override it with a method - that throws exceptions when a document is invalid to block writing invalid data to the database. - -* `note.save()` - Calls `validate()` and then writes the document to the database. - - * _Returns_ - a self-reference to the instance - -* `note.update(props, save=true)` - Update this instance - - * /props/ - a deeply nested object that will be applied to this instance - * /save/ - a boolean to save this document after updating. - * _Returns_ - a self-reference to the instance - -* `note.addAttachment(name, blob)` - Add a named attachment to this instance. - - * /name/ - the name used to reference the attachment - * /blob/ - a blob instance of the actual data - * _Returns_ - a self-reference to the instance - -* `note.getAttachment(name)` - Retrieve a named attachment from this instance. - - * /name/ - the name of the attachment specified in `addAttachment` - * _Returns_ - a Blob instance of the attachment data - -* `note.removeAttachment(name)` - Remove a named attachment from this instance. - - * /name/ - the name of the attachment specified in `addAttachment` - * _Returns_ - - -* `note.delete()` - Flag this document as `_deleted` == `true`. This will cause it to not show up in - `find` results. (_Note_: documents are left in the database in this state to allow for deletion to - be synced to other nodes. To truly remove documents, - [compact your database](https://pouchdb.com/api.html#compaction).) diff --git a/packages/pouchorm/rollup.config.js b/packages/pouchorm/rollup.config.js deleted file mode 100644 index a832a0d..0000000 --- a/packages/pouchorm/rollup.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import json from 'rollup-plugin-json'; -import babel from 'rollup-plugin-babel'; - -const babelConfig = { - env: { - es6: true, - browser: true - }, - plugins: [], - presets: ['es2015-rollup'] -}; - -export default { - entry: 'src/index.js', - moduleName: 'pouchorm', - plugins: [json(), babel(babelConfig)], - output: { - format: 'umd', - file: 'dist/pouchorm.js' - } -}; diff --git a/packages/pouchorm/spec/pouchorm.spec.js b/packages/pouchorm/spec/pouchorm.spec.js deleted file mode 100644 index 6c93e90..0000000 --- a/packages/pouchorm/spec/pouchorm.spec.js +++ /dev/null @@ -1,7 +0,0 @@ -import { PouchORM } from '../src/index.js'; - -// describe('A PouchORM Document', () => { -// it('.', () => { -// expect().nothing(); -// }); -// }); diff --git a/packages/pouchorm/spec/support/jasmine.json b/packages/pouchorm/spec/support/jasmine.json deleted file mode 100644 index b52c9e6..0000000 --- a/packages/pouchorm/spec/support/jasmine.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "spec_dir": "spec", - "spec_files": ["**/*[sS]pec.js"], - "helpers": ["helpers/**/*.js"], - "stopSpecOnExpectationFailure": false, - "random": false -} diff --git a/packages/pouchorm/src/plugin.js b/packages/pouchorm/src/plugin.js deleted file mode 100644 index 624c11d..0000000 --- a/packages/pouchorm/src/plugin.js +++ /dev/null @@ -1,113 +0,0 @@ -import { LiveArray } from './livearray.js'; -import { Watcher } from './watcher.js'; -import { isObject } from './utils.js'; - -export function PouchORM(PouchDB) { - PouchDB.registerType = (name, cls, db) => { - const prefix = name.toLowerCase(); - const _db = db || PouchDB(prefix); - _db.setMaxListeners(1000); - const _baseSelector = Object.freeze({ - _id: { $gt: `${prefix}_0`, $lt: `${prefix}_\ufff0` } - }); - const watch = Watcher(_db, _baseSelector, { include_docs: true }); - - if (!cls.hasOwnProperty('validate')) { - // warn(`${cls.name} has no validation.`); - } - - const instantiate = doc => new cls(doc); - - async function find(idOrSelector, opts = {}) { - if (typeof idOrSelector === 'string') { - return instantiate(await _db.get(idOrSelector)); - } - - const isSelector = isObject(idOrSelector); - - const selector = Object.assign( - isSelector && idOrSelector._deleted ? { _deleted: true } : { _deleted: { exists: false } }, - isSelector ? idOrSelector : _baseSelector - ); - if (opts.index) { - opts.use_index = [prefix, opts.index]; - delete opts.index; - } - if (opts.live) { - opts.mapper = instantiate; - return LiveArray(_db, idOrSelector, opts); - } - return (await _db.find(Object.assign({ selector: idOrSelector }, opts))).docs.map(instantiate); - } - - async function getOrCreate(props) { - let doc = await new cls(props); - try { - await doc.save(); - } catch (e) { - if (e.status !== 409) { - throw e; - } - doc = await find(doc._id); - } - return doc; - } - - async function _delete(id) { - try { - const doc = await find(id); - await doc.delete(); - } catch (e) { - if (e.status !== 404) { - throw e; - } - } - } - - async function _index(name, fields) { - return _db.createIndex({ - index: { - ddoc: prefix, - fields, - name - } - }); - } - - Object.defineProperties(cls.prototype, { - _name: { value: name }, - _prefix: { value: prefix }, - _db: { value: _db }, - _cls: { value: cls }, - _baseSelector: { value: _baseSelector } - }); - - const methods = { - getOrCreate: { value: getOrCreate }, - find: { value: find }, - index: { value: _index }, - delete: { value: _delete }, - subscribe: { value: watch } - }; - - Object.defineProperties( - cls, - Object.assign( - { - db: { value: _db }, - name: { value: name }, - prefix: { value: prefix }, - selector: { value: _baseSelector } - }, - Object.entries(methods) - .filter(([name, obj]) => cls[name] === undefined) - .reduce((acc, [name, obj]) => { - acc[name] = obj; - return acc; - }, {}) - ) - ); - - return cls; - }; -} diff --git a/packages/pouchorm/src/type.js b/packages/pouchorm/src/type.js deleted file mode 100644 index 4ae5923..0000000 --- a/packages/pouchorm/src/type.js +++ /dev/null @@ -1,73 +0,0 @@ -import { pouchDocHash, deepAssign } from './utils.js'; - -export class TypeSpec { - constructor(props) { - this._populateId(props); - Object.assign(this, { $$links: {} }, props, { $$type: this._prefix }); - } - - static getUniqueID(doc) { - throw 'NotImplemented'; - } - - static isType(doc) { - return doc && doc.$$type === TypeSpec.prefix; - } - - static validate(doc) {} - - instantiate(doc) { - return new this._cls(docs); - } - - _populateId(doc) { - if (!doc._id) { - doc._id = `${this._prefix}_${this._cls.getUniqueID(doc)}`; - } - return doc; - } - - _hash() { - return pouchDocHash(this); - } - - async delete() { - return await this.update({ _deleted: true }); - } - - async save() { - this._cls.validate(this); - const { rev } = await this._db.put(this); - this._rev = rev; - return this; - } - - async addAttachment(attName, dataBlob) { - const { rev } = await this._db.putAttachment( - this._id, - attName, - this._rev, - dataBlob, - dataBlob.type - ); - - this._rev = rev; - return this; - } - - async getAttachment(attName) { - return await this._db.getAttachment(this._id, attName); - } - - async removeAttachment(attName) { - return await this._db.removeAttachment(this._id, attName, this._rev); - } - - async update(props, save = true) { - deepAssign(this, props); - if (save) { - await this.save(); - } - return this; - } -} diff --git a/packages/pouchtype/README.md b/packages/pouchtype/README.md new file mode 100644 index 0000000..d9bc899 --- /dev/null +++ b/packages/pouchtype/README.md @@ -0,0 +1,137 @@ +# PouchType + +An type-based abstraction layer over PouchDB inspired by [Hood.ie](https://hood.ie/) and +[Django](https://djangoproject.com) + +## Extending the TypeHandler class + +PouchType works by extending the _TypeHandler_ class with methods to define how a document type +should be handled. The resulting class is instantiated with a PouchDB instance and used to interact +with documents of that type. + +All subclasses of _TypeHandler_ must override the `getUniqueID` method. This method should return a +unique string for documents of this type. (NOTE: The document id will be prefixed with the +type_string as well to avoid collisions across types.) + +```js +import { PouchDB } from 'pouchdb'; +import { TypeHandler } from 'PouchType'; + +const PDB = PouchDB.plugin(find); // PouchType requires the find plugin. + +class ContactHandler extends TypeHandler { + getUniqueID(doc) { + return doc.email; + } + + validate(doc) { + super.validate(doc); + + if (typeof doc.email != 'string') { + throw new Error('email property is required'); + } else if (doc.email.length <= 2) { + throw new Error('email must be longer than 2 characters'); + } + } +} +``` + +## TypeHandler management methods + +### getUniqueID(doc) + +_This method must be overridden in subclasses._ + +Return a unique string that will be used to populate `doc._id` if it isn't already populated. + +### hash(doc) + +Returns a hash string of the current document for comparison. By default this is +"`doc._id`:`doc._rev`" and will work as long as the hash is only taken after any changes are saved. +You may wish to override this to provide content-specific hashing. + +### index(name, fields) + +Create a index to be used in a `filter` selector and sort options. Specify the `name` of the index +and the `fields` as an array of strings of the document properties to include in the index. + +### isType(doc) + +Check if the passed `doc` belongs to this handler. + +### validate(doc) + +Check if the passed `doc` has valid data before it is written. Invalidation happens by raising an +exception. For more fine-grained validation, refer to the +[pouchdb-validation](https://github.com/pouchdb/pouchdb-validation) plugin or the +[validate_doc_update](http://guide.couchdb.org/draft/validation.html) function. + +## TypeHandler query methods + +### get(id) + +Return the document referenced by `id` or `null` if the document does not exist. + +### getOrCreate(doc, defaults={}) + +If the passed document doesn't have a `_id` property, populate it. Try to lookup a document with the +`id`. If it exists, update it with the properties in `doc`. If it does not exist, add the properties +in `defaults` to `props` and save the new document. + +### filter(selector, options={}) + +Return an array of documents that match the criteria in `selector`. `options.index` can contain the +`name` passed to `index()` if needed. All other option properties are passed through to the +[PouchDB.find()](https://pouchdb.com/api.html#query_index) + +### watch(selector, options={}) + +The parameters for `watch()` are identical to `filter()` but `watch()` returns a +[computed](../frptools/README.md#computed) instance that will call subscribers whenever any data +matching the selector changes. + +## TypeHandler change methods + +### remove(docOrId) + +`remove` accepts a document or an id string. + +Flag this document as `doc._deleted == true`. This will cause it to not show up in `get`, `filter` +or `watch` results. (_Note_: documents are left in the database in this state to allow for deletion +to be synced to other nodes. To truly remove documents, +[compact your database](https://pouchdb.com/api.html#compaction).) + +### save(doc) + +If the document does not have properties `_id` or `type`, populate them appropriately. Run +`TypeHandler.validate` on the document. If validate doesn't throw any errors, then save the document +to the database. It's `_rev` property will be updated to reflect the revision in the database. + +### update(doc, props) + +Deeply assign `props` to the passed `doc` object and `save()` it. + +### addAttachment(doc, key, blob) + +Attach the passed `blob` to the document referenced with the `key` string. + +### removeAttachment(doc, key) + +Remove a previously attached blob at `key`. + +### getAttachment(doc, key) + +Return a previously attached blob at `key` or `null` if none exists. + +## Using Types + +```js +const db = PDB('type_example'); + +export const Contact = new ContactHandler(db, 'contact'); + +const doc = await Contact.getOrCreate({ + name: "John Doe", + email: "jd@example.com" +}); +``` diff --git a/packages/pouchorm/package.json b/packages/pouchtype/package.json similarity index 67% rename from packages/pouchorm/package.json rename to packages/pouchtype/package.json index 2b1226e..05ace4b 100644 --- a/packages/pouchorm/package.json +++ b/packages/pouchtype/package.json @@ -1,7 +1,7 @@ { - "name": "pouchorm", + "name": "pouchtype", "version": "1.0.0", - "description": "Document Abstraction Layer for PouchDB", + "description": "Document Management Layer for PouchDB", "main": "src/index.js", "files": ["dist", "lib", "src"], "scripts": { @@ -10,7 +10,7 @@ "author": "Timothy Farrell (https://github.com/explorigin)", "license": "Apache-2.0", "dependencies": { - "frptools": "^3.1.0" - }, - "devDependencies": {} + "frptools": "~3.2.1", + "pouchdb": "~7.0.0" + } } diff --git a/packages/pouchorm/spec/livearray.spec.js b/packages/pouchtype/spec/livearray.spec.js similarity index 54% rename from packages/pouchorm/spec/livearray.spec.js rename to packages/pouchtype/spec/livearray.spec.js index f5d0eb8..139cdd4 100644 --- a/packages/pouchorm/spec/livearray.spec.js +++ b/packages/pouchtype/spec/livearray.spec.js @@ -14,8 +14,8 @@ describe('A LiveArray', () => { }; }); - it('returns a computed (subscribable).', () => { - const la = LiveArray(fakePouch, selector, opts); + it('returns a computed (subscribable).', async () => { + const la = await LiveArray(fakePouch, selector, opts); expect(typeof la).toEqual('function'); expect(typeof la.subscribe).toEqual('function'); @@ -23,37 +23,7 @@ describe('A LiveArray', () => { expect(JSON.stringify(la())).toEqual('[]'); }); - it('has a "ready" subscribable that is fired once and subscribers cleared when the main subscribable has data for the first time.', () => { - let sub = null; - - const db = { - changes: options => { - return db; - }, - on: (eventName, callback) => { - if (eventName == 'change') { - sub = callback; - } - expect(['change', 'error'].indexOf(eventName) !== -1).toBeTruthy(); - return db; - }, - cancel: () => (sub = null), - find: async selector => { - return Promise.resolve({ docs: [{ _id: 234 }] }); - } - }; - - const la = LiveArray(db, selector, opts); - - return new Promise((resolve, reject) => { - const unsub = la.ready.subscribe(() => { - unsub(); - resolve(); - }); - }); - }); - - it('fires when data changes.', () => { + it('fires when data changes.', async () => { let state = 0; const changes = { 234: { id: 234, deleted: false, doc: { _id: 234 } }, @@ -89,29 +59,21 @@ describe('A LiveArray', () => { } }; - const la = LiveArray(db, selector, opts); + const la = await LiveArray(db, selector, opts); state = 1; let innerState = 0; - return new Promise((resolve, reject) => { - const unsub = la.ready.subscribe(() => { - la.subscribe(data => { - if (data.length) { - expect(data[0]._id).toEqual(parseInt(changeKeys[innerState])); - } - innerState += 1; + la.subscribe(data => { + if (data.length) { + expect(data[0]._id).toEqual(parseInt(changeKeys[innerState])); + } + innerState += 1; + }); - resolve(); - }); - - changeKeys.forEach(id => { - const doc = changes[parseInt(id)]; - sub(doc); - }); - - unsub(); - }); + changeKeys.forEach(id => { + const doc = changes[parseInt(id)]; + sub(doc); }); }); }); diff --git a/packages/pouchtype/spec/typehandler.spec.js b/packages/pouchtype/spec/typehandler.spec.js new file mode 100644 index 0000000..b7324c2 --- /dev/null +++ b/packages/pouchtype/spec/typehandler.spec.js @@ -0,0 +1,360 @@ +import { TypeHandler } from '../src/index.js'; + +const PDB = PouchDB.plugin(find); + +class ContactHandler extends TypeHandler { + getUniqueID(doc) { + return doc.email; + } + + validate(doc) { + if (typeof doc.email != 'string') { + throw new Error('email property is required'); + } else if (doc.email.length <= 2) { + throw new Error('email must be more than 2 characters'); + } + } +} + +class LocationHandler extends TypeHandler { + getUniqueID(doc) { + return doc.latlon; + } +} + +const notDesignDocs = d => !d.id.startsWith('_design'); + +function compareDataToDoc(dataObj, doc) { + Object.keys(dataObj).forEach(k => { + expect(doc[k]).toEqual(dataObj[k]); + }); +} + +describe('PouchType Handler', () => { + const db = new PDB('pouchtype-test', { adapter: 'memory' }); + const Contact = new ContactHandler(db, 'contact'); + const Location = new LocationHandler(db, 'location'); + + Contact.index('lastIndex', ['last']); + + const dataset = [ + { + first: 'Jane', + last: 'Doe', + email: 'jd@example.com', + type: 'contact', + _id: 'contact_jd@example.com' + }, + { + first: 'Bob', + last: 'Smith', + email: 'bs@example.com', + type: 'contact', + _id: 'contact_bs@example.com' + }, + { + description: 'Home', + latlon: '12345', + type: 'location', + _id: 'location_12345' + }, + { + first: 'Joe', + last: 'Smithers', + email: 'js@example.com', + type: 'contact', + _id: 'contact_js@example.com' + } + ]; + + async function flushDb() { + const res = await db.allDocs(); + await Promise.all(res.rows.filter(notDesignDocs).map(d => db.remove(d.id, d.value.rev))).catch( + () => {} + ); + await db.compact(); + } + + afterEach(flushDb); + + it('.getOrCreate() gets existing records.', async () => { + const doc = { + first: 'Jane', + last: 'Doe', + email: 'jd@example.com' + }; + await db.bulkDocs(dataset); + const existing = await db.get(dataset[0]._id); + const instance = await Contact.getOrCreate(doc); + expect(instance._id).toEqual(existing._id); + expect(instance._rev).toEqual(existing._rev); + }); + + it('.getOrCreate() saves non-existing records and populates _id and _rev.', async () => { + const doc = { + first: 'Jill', + last: 'Doener', + email: 'jd2@example.com' + }; + const expectedId = `${Contact.type}_${doc.email}`; + try { + await db.get(expectedId); + fail('db.get() should throw when passed an id of a removed document.'); + return; + } catch (e) { + expect(e.status).toBe(404); + } + + const instance = await Contact.getOrCreate(doc); + expect(instance._id).toEqual(expectedId); + expect(instance._rev).toBeTruthy(); + compareDataToDoc(doc, instance); + const savedDoc = await db.get(expectedId); + expect(savedDoc._id).toEqual(expectedId); + expect(savedDoc._rev).toBeTruthy(); + compareDataToDoc(doc, savedDoc); + }); + + it('.get() returns a document when it exists.', async () => { + await db.bulkDocs(dataset); + const instance = await Contact.get(dataset[1]._id); + compareDataToDoc(dataset[1], instance); + }); + + it(".get() returns null when it doesn't exist or has been removed.", async () => { + await db.bulkDocs(dataset); + const empty = await Contact.get('does_not_exist'); + expect(empty).toBeNull(); + const doc = await db.get(dataset[1]._id); + doc._deleted = true; + await db.put(doc); + const empty2 = await Contact.get('does_not_exist'); + expect(empty).toBeNull(); + }); + + it('.get() only returns records for its type.', async () => { + await db.bulkDocs(dataset); + const instance = await Contact.get(dataset.filter(d => d.type !== Contact.type)[0]._id); + expect(instance).toBeNull(); + }); + + it('.filter() returns an array of matching instances.', async () => { + await db.bulkDocs(dataset); + const res = await Contact.filter({ last: { $regex: /^Smith.*/ } }); + expect(res.length).toBe(2); + }); + + it('.filter() only returns records for its type.', async () => { + await db.bulkDocs(dataset); + const res = await Contact.filter({}); + expect(res.length).toBe(3); + res.forEach(c => expect(c.type).toBe(Contact.type)); + }); + + it('.isType(instance) identifies instances of the type.', async () => { + const instance = await Contact.getOrCreate({ + first: 'Bob', + last: 'Smith', + email: 'bs@example.com' + }); + + expect(Contact.isType(instance)).toBe(true); + }); + + it('.isType(doc) detects documents of the type.', async () => { + const data = await Contact.getOrCreate({ + first: 'Bob', + last: 'Smith', + email: 'bs@example.com' + }); + const doc = await db.get(data._id); + + expect(Contact.isType(doc)).toBe(true); + }); + + it('.remove() sets the ._deleted property and documents are no longer gettable.', async () => { + const id = dataset[1]._id; + await db.bulkDocs(dataset); + const doc = await Contact.get(id); + await Contact.remove(doc); + expect(doc._deleted).toBe(true); + try { + await db.get(id); + fail('db.get() should throw when passed an id of a removed document.'); + return; + } catch (e) { + expect(e.status).toBe(404); + } + const doc2 = await Contact.get(id); + expect(doc2).toBeNull(); + }); + + it('.save() populates ._id and .type properties and writes to the db.', async () => { + const doc = { + first: 'Bob', + last: 'Smith', + email: 'bs@example.com' + }; + await Contact.save(doc); + const d = await db.get(doc._id); + expect(Contact.isType(d)).toBe(true); + expect(d._id).toBeTruthy(); + expect(d.type).toEqual(Contact.type); + compareDataToDoc(doc, d); + }); + + it('.validate() can interrupt save() by throwing an exception.', async () => { + const doc = { + first: 'Bob', + last: 'Smith', + email: 'bs' + }; + try { + await Contact.save(doc); + } catch (e) { + expect(e.message).toBe('email must be more than 2 characters'); + return; + } + fail('TypeHandler.save() should call validate on save()'); + }); + + it('.update() will deeply apply object properties to a document.', async () => { + const data = { + first: 'Bob', + last: 'Smitherines', + email: 'bsmitherines@example.com', + addresses: { + home: '123 Privet Drive' + } + }; + const doc = await Contact.save(data); + await Contact.update(doc, { addresses: { home: '221B Baker Street' } }); + const doc2 = await Contact.get(doc._id); + expect(doc2.addresses.home).toEqual(doc.addresses.home); + }); + + it('.update() only updates the database when the underlying document changes and save is not precluded.', async () => { + const data = { + first: 'Bob', + last: 'Smitherines', + email: 'bsmitherines@example.com' + }; + const doc = await Contact.save(data); + spyOn(Contact, 'save'); + await Contact.update(doc, { last: 'Shell', email: 'bshell@example.com' }); + expect(Contact.save).toHaveBeenCalledTimes(1); + await Contact.update(doc, { last: 'Shell' }); + expect(Contact.save).toHaveBeenCalledTimes(1); + await Contact.update(doc, { last: 'Tootsie' }, false); + expect(Contact.save).toHaveBeenCalledTimes(1); + + // TODO: Potentially undesirable/non-intuitive behavior here. Update calls save even though the data hasn't truly changed from the last saved state. + await Contact.update(doc, { last: 'Shell' }); + expect(Contact.save).toHaveBeenCalledTimes(2); + }); + + it('.watch() returns a subscribable LiveArray of matching instances.', done => { + let expectedLength = 0; + let checkCount = 0; + let sub; + let livedata; + + function poll(fn, val) { + return new Promise(resolve => { + const wrap = () => { + if (fn()) { + resolve(val); + } else { + setTimeout(wrap, 5); + } + }; + wrap(); + }); + } + + return Contact.watch({ watchTest: { $eq: true } }) + .then(_ld => { + livedata = _ld; + sub = livedata.subscribe(data => { + checkCount += 1; + expect(data.length).toBe(expectedLength); + }); + return db.bulkDocs(dataset); + }) + .then(_ => + poll(() => { + return livedata().length === 0; + }) + ) + .then(_ => { + expectedLength = 1; + return db.put({ + first: 'Bill', + last: 'Smitherts', + email: 'bsmithers@example.com', + _id: 'contact_bsmithers@example.com', + type: 'contact', + watchTest: true + }); + }) + .then(_ => + poll(() => { + return livedata().length === 1; + }) + ) + .then(_ => { + return db.put({ + first: 'Bart', + last: 'Smitty', + email: 'bsmitty@example.com', + type: 'contact', + _id: 'contact_bsmitty@example.com' + }); + }) + .then(res => { + return poll(() => { + return livedata().length === 1; + }, res); + }) + .then(res => { + expectedLength = 2; + db.put({ + first: 'Bart', + last: 'Smitty', + email: 'bsmitty@example.com', + type: 'contact', + _id: 'contact_bsmitty@example.com', + _rev: res.rev, + watchTest: true + }); + return poll(() => { + return livedata().length === 2; + }); + }) + .then(_ => { + expect(livedata().length).toBe(expectedLength); + }) + .then(done); + }); + + it('.index() creates an index for non-id sorting.', async () => { + await db.bulkDocs(dataset); + try { + const res = await Contact.filter( + { + last: { $gte: '' } + }, + { + sort: ['last'], + index: 'lastIndex' + } + ); + expect(res.length).toEqual(3); + expect(res[0].last).toEqual('Doe'); + expect(res[1].last).toEqual('Smith'); + expect(res[2].last).toEqual('Smithers'); + } catch (e) { + fail(e); + } + }); +}); diff --git a/packages/pouchorm/spec/watcher.spec.js b/packages/pouchtype/spec/watcher.spec.js similarity index 100% rename from packages/pouchorm/spec/watcher.spec.js rename to packages/pouchtype/spec/watcher.spec.js diff --git a/packages/pouchorm/src/index.js b/packages/pouchtype/src/index.js similarity index 51% rename from packages/pouchorm/src/index.js rename to packages/pouchtype/src/index.js index 394e416..7eae1e1 100644 --- a/packages/pouchorm/src/index.js +++ b/packages/pouchtype/src/index.js @@ -1,3 +1,2 @@ import { isObject, deepAssign, pouchDocHash, pouchDocArrayHash } from './utils.js'; -export { TypeSpec } from './type.js'; -export { PouchORM } from './plugin.js'; +export { TypeHandler } from './type.js'; diff --git a/packages/pouchorm/src/livearray.js b/packages/pouchtype/src/livearray.js similarity index 62% rename from packages/pouchorm/src/livearray.js rename to packages/pouchtype/src/livearray.js index ca663b8..fe4a5f7 100644 --- a/packages/pouchorm/src/livearray.js +++ b/packages/pouchtype/src/livearray.js @@ -5,20 +5,17 @@ import { pouchDocArrayHash } from './utils.js'; // LiveArray is a subscribable property function that always returns the db results that match // the provided selector and calls subscribers when the results change. -export function LiveArray(db, selector, opts = {}) { - const mapper = opts.mapper || id; - opts.mapper && delete opts.mapper; +export async function LiveArray(db, selector, opts = {}) { opts.include_docs = true; const _watcher = Watcher(db, selector, opts); let changeSub = null; - const ready = prop(false); + const paginator = opts.paginator; const data = prop({ docs: [] }); - const docs = computed(r => r.docs.map(mapper), [data], pouchDocArrayHash); + const docs = computed(r => r.docs, [data], pouchDocArrayHash); const cleanup = () => { docs.unsubscribeAll(); - ready.unsubscribeAll(); if (changeSub) { changeSub(); changeSub = null; @@ -27,21 +24,22 @@ export function LiveArray(db, selector, opts = {}) { }; const refresh = async function refresh(...args) { - data(await db.find({ selector })); + const queryOpts = { selector }; + if (paginator) { + Object.assign(queryOpts, paginator.queryOptions()); + } + data(await db.find(queryOpts)); }; - docs.ready = ready; docs.cleanup = cleanup; docs.selector = selector; docs.db = db; - refresh() - .then(() => { - changeSub = _watcher(refresh); - ready(true); - }) - .then(() => { - ready.unsubscribeAll(); - }); + await refresh(); + changeSub = _watcher(refresh); + if (paginator) { + paginator.queryOptions.subscribe(refresh); + } + return docs; } diff --git a/packages/pouchtype/src/type.js b/packages/pouchtype/src/type.js new file mode 100644 index 0000000..472d024 --- /dev/null +++ b/packages/pouchtype/src/type.js @@ -0,0 +1,132 @@ +import { pouchDocHash, deepAssign } from './utils.js'; +import { LiveArray } from './livearray.js'; + +export class TypeHandler { + constructor(db, typeKey) { + this.db = db; + this.type = typeKey; + } + + getUniqueID(doc) { + return undefined; // To be overridden if you want deterministic IDs. + } + + _makeID(doc) { + return `${this.type}_${this.getUniqueID(doc)}`; + } + + isType(doc) { + return doc && doc.type === this.type; + } + + hash(doc) { + return pouchDocHash(doc); + } + + async validate(doc) { + if (!this.isType(doc)) { + throw TypeError(`Document "${doc.type}:${doc._id}" does not match type "${this.type}"`); + } + } + + async remove(docOrId) { + const doc = typeof docOrId === 'string' ? await this.get(docOrId) : docOrId; + return await this.update(doc, { _deleted: true }); + } + + async save(doc) { + if (!doc.type) { + doc.type = this.type; + } + this.validate(doc); + if (!doc._id) { + doc._id = this._makeID(doc); + } + if (!doc.$$links) { + doc.$$links = {}; + } + + const { rev } = await this.db.put(doc); + doc._rev = rev; + return doc; + } + + async addAttachment(doc, attName, dataBlob) { + const { rev } = await this.db.putAttachment(doc._id, attName, doc._rev, dataBlob, dataBlob.type); + + doc._rev = rev; + return doc; + } + + async getAttachment(doc, attName) { + return await this.db.getAttachment(doc._id, attName); + } + + async removeAttachment(doc, attName) { + return await this.db.removeAttachment(doc._id, attName, doc._rev); + } + + async update(doc, props, save = true) { + if (deepAssign(doc, props) && save) { + await this.save(doc); + } + return doc; + } + + _filterOpts(selector, opts) { + selector.type = { $eq: this.type }; + opts.selector = selector; + if (opts.index) { + opts.use_index = `${this.type}_${opts.index}`; + delete opts.index; + } + return opts; + } + + async filter(selector, opts = {}) { + const res = await this.db.find(this._filterOpts(selector, opts)); + return res.docs; + } + + async watch(selector, opts = {}) { + opts.live = true; + this._filterOpts(selector, opts); + return await LiveArray(this.db, opts.selector, opts); + } + + async get(id) { + try { + const doc = await this.db.get(id); + return this.isType(doc) ? doc : null; + } catch (e) { + if (e.status === 404) { + return null; + } + throw e; + } + } + + async getOrCreate(props, defaults = {}) { + const doc = Object.assign({}, defaults, props); + if (!doc._id) { + doc._id = this._makeID(doc); + } + const existing_doc = doc._id && (await this.get(doc._id)); + + if (existing_doc) { + return this.update(existing_doc, props); + } + + return await this.save(doc); + } + + async index(name, fields) { + return this.db.createIndex({ + index: { + ddoc: `${this.type}_${name}`, + fields: fields, + name + } + }); + } +} diff --git a/packages/pouchorm/src/utils.js b/packages/pouchtype/src/utils.js similarity index 68% rename from packages/pouchorm/src/utils.js rename to packages/pouchtype/src/utils.js index a398627..a141840 100644 --- a/packages/pouchorm/src/utils.js +++ b/packages/pouchtype/src/utils.js @@ -1,17 +1,24 @@ export function deepAssign(to, ...rest) { + let updated = false; for (let src of rest) { for (let prop in src) { const value = src[prop]; + const oldValue = to[prop]; if (typeof value === 'object' && !Array.isArray(value)) { - to[prop] = deepAssign(to[prop] || {}, value); + if (typeof oldValue !== 'object') { + to[prop] = {}; + updated = true; + } + updated = deepAssign(to[prop], value) || updated; } else if (value === undefined && to[prop] !== undefined) { delete to[prop]; - } else { + } else if (value !== oldValue) { + updated = true; to[prop] = value; } } } - return to; + return updated; } export const pouchDocHash = d => (isObject(d) ? `${d._id}:${d._rev}` : d); diff --git a/packages/pouchorm/src/watcher.js b/packages/pouchtype/src/watcher.js similarity index 100% rename from packages/pouchorm/src/watcher.js rename to packages/pouchtype/src/watcher.js diff --git a/packages/pouchtype/test.json b/packages/pouchtype/test.json new file mode 100644 index 0000000..2ed9e5a --- /dev/null +++ b/packages/pouchtype/test.json @@ -0,0 +1,9 @@ +{ + "spec_dir": "spec", + "spec_files": ["**/*[sS]pec.js"], + "lib_files": [ + "node_modules/pouchdb/dist/pouchdb.js", + "node_modules/pouchdb/dist/pouchdb.memory.js", + "node_modules/pouchdb/dist/pouchdb.find.js" + ] +}