From 3a5e0f8af8e1bcd9521b39b868d0629402813ef1 Mon Sep 17 00:00:00 2001 From: vinz243 Date: Sat, 14 Apr 2018 18:25:28 +0200 Subject: [PATCH] feat: add SlothView and SlothIndex --- src/decorators/SlothIndex.ts | 30 +++++++ src/decorators/SlothView.ts | 45 ++++++++++ src/models/ProtoData.ts | 7 ++ src/models/SlothDatabase.ts | 95 ++++++++++++++++++++- src/slothdb.ts | 2 + src/utils/getProtoData.ts | 3 +- test/integration/Track.ts | 12 ++- test/integration/views.test.ts | 51 +++++++++++ test/unit/decorators/SlothView.test.ts | 37 ++++++++ test/unit/models/SlothDatabase.test.ts | 112 +++++++++++++++++++++++++ test/utils/emptyProtoData.ts | 3 +- 11 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 src/decorators/SlothIndex.ts create mode 100644 src/decorators/SlothView.ts create mode 100644 test/integration/views.test.ts create mode 100644 test/unit/decorators/SlothView.test.ts diff --git a/src/decorators/SlothIndex.ts b/src/decorators/SlothIndex.ts new file mode 100644 index 0000000..f7ce8b7 --- /dev/null +++ b/src/decorators/SlothIndex.ts @@ -0,0 +1,30 @@ +import BaseEntity from '../models/BaseEntity' +import getSlothData from '../utils/getSlothData' +import { join } from 'path' +import getProtoData from '../utils/getProtoData' +import SlothView from './SlothView' + +/** + * Creates an index for a field. It's a view function that simply emits + * the document key + * + * @see [[SlothDatabase.queryDocs]] + * @export + * @template S + * @param {(doc: S, emit: Function) => void} fn the view function, as arrow or es5 function + * @param {string} [docId='views'] the _design document identifier + * @param {string} [viewId] the view identifier, default by_ + * @returns the decorator to apply on the field + */ +export default function SlothIndex( + viewId?: V, + docId?: string +) { + return (target: object, key: string) => { + SlothView( + new Function('doc', 'emit', `emit(doc['${key}'].toString());`) as any, + viewId, + docId + )(target, key) + } +} diff --git a/src/decorators/SlothView.ts b/src/decorators/SlothView.ts new file mode 100644 index 0000000..5fe891b --- /dev/null +++ b/src/decorators/SlothView.ts @@ -0,0 +1,45 @@ +import BaseEntity from '../models/BaseEntity' +import getSlothData from '../utils/getSlothData' +import { join } from 'path' +import getProtoData from '../utils/getProtoData' + +/** + * Creates a view for a field. This function does not modify the + * behavior of the current field, hence requires another decorator + * such as SlothURI or SlothField. The view will be created by the SlothDatabase + * + * @export + * @template S + * @param {(doc: S, emit: Function) => void} fn the view function, as arrow or es5 function + * @param {string} [docId='views'] the _design document identifier + * @param {string} [viewId] the view identifier, default by_ + * @returns the decorator to apply on the field + */ +export default function SlothView( + fn: (doc: S, emit: Function) => void, + viewId?: V, + docId = 'views' +) { + return (target: object, key: string) => { + const desc = Reflect.getOwnPropertyDescriptor(target, key) + + if (desc) { + if (!desc.get && !desc.set) { + throw new Error('Required SlothView on top of another decorator') + } + } + + const fun = `function (__doc) { + (${fn.toString()})(__doc, emit); + }` + + const { views } = getProtoData(target, true) + + views.push({ + id: docId, + name: viewId || `by_${key}`, + function: fn, + code: fun + }) + } +} diff --git a/src/models/ProtoData.ts b/src/models/ProtoData.ts index abcd532..cb607cb 100644 --- a/src/models/ProtoData.ts +++ b/src/models/ProtoData.ts @@ -27,5 +27,12 @@ export default interface ProtoData { key: string }[] + views: { + id: string + name: string + function: Function + code: string + }[] + rels: (RelationDescriptor & { key: string })[] } diff --git a/src/models/SlothDatabase.ts b/src/models/SlothDatabase.ts index 8344120..8a45d6c 100644 --- a/src/models/SlothDatabase.ts +++ b/src/models/SlothDatabase.ts @@ -10,9 +10,13 @@ import { join } from 'path' * * @typeparam S the database schema * @typeparam E the Entity - * @typeparam T the entity constructor + * @typeparam V the (optional) view type that defines a list of possible view IDs */ -export default class SlothDatabase> { +export default class SlothDatabase< + S, + E extends BaseEntity, + V extends string = never +> { _root: string /** * @@ -53,6 +57,31 @@ export default class SlothDatabase> { } } + /** + * Queries and maps docs to Entity objects + * + * @param factory the pouch factory + * @param view the view identifier + * @param startKey the optional startkey + * @param endKey the optional endkey + */ + queryDocs( + factory: PouchFactory, + view: V, + startKey = '', + endKey = join(startKey, '\uffff') + ) { + return factory(this._name) + .query(view, { + startkey: startKey, + endkey: endKey, + include_docs: true + }) + .then(({ rows }) => { + return rows.map(({ doc }) => new this._model(factory, doc as any)) + }) + } + /** * Returns a database that will only find entities with _id * starting with the root path @@ -141,6 +170,17 @@ export default class SlothDatabase> { return new this._model(factory, props) } + /** + * Create a new model instance and save it to database + * @param factory The database factory to attach to the model + * @param props the entity properties + * @returns an entity instance + */ + put(factory: PouchFactory, props: Partial) { + const doc = new this._model(factory, props) + return doc.save().then(() => doc) + } + /** * Subscribes a function to PouchDB changes, so that * the function will be called when changes are made @@ -205,6 +245,14 @@ export default class SlothDatabase> { } } + /** + * Creates view documents (if required) + * @param factory + */ + async initSetup(factory: PouchFactory) { + await this.setupViews(factory) + } + protected getSubscriberFor(factory: PouchFactory) { return this._subscribers.find(el => el.factory === factory) } @@ -212,4 +260,47 @@ export default class SlothDatabase> { protected dispatch(action: ChangeAction) { this._subscribers.forEach(({ sub }) => sub(action)) } + + private setupViews(factory: PouchFactory): Promise { + const { views } = getProtoData(this._model.prototype) + const db = factory(this._name) + + const promises = views.map(({ name, id, code }) => async () => { + const views = {} + let _rev + + try { + const doc = (await db.get(`_design/${id}`)) as any + + if (doc.views[name] && doc.views[name].map === code) { + // view already exists and is up-to-date + return + } + + Object.assign(views, doc.views) + + _rev = doc._rev + } catch (err) { + // Do nothing + } + + await db.put(Object.assign( + {}, + { + _id: `_design/${id}`, + views: { + ...views, + [name]: { + map: code + } + } + }, + _rev ? { _rev } : {} + ) as any) + }) + + return promises.reduce((acc, fn) => { + return acc.then(() => fn()) + }, Promise.resolve()) + } } diff --git a/src/slothdb.ts b/src/slothdb.ts index 7768d63..c5dcf68 100644 --- a/src/slothdb.ts +++ b/src/slothdb.ts @@ -11,6 +11,7 @@ import { import PouchFactory from './models/PouchFactory' import { belongsToMapper } from './utils/relationMappers' import SlothDatabase from './models/SlothDatabase' +import SlothView from './decorators/SlothView' export { SlothEntity, @@ -25,5 +26,6 @@ export { BelongsToDescriptor, HasManyDescriptor, SlothDatabase, + SlothView, belongsToMapper } diff --git a/src/utils/getProtoData.ts b/src/utils/getProtoData.ts index a3914f3..595b87b 100644 --- a/src/utils/getProtoData.ts +++ b/src/utils/getProtoData.ts @@ -19,7 +19,8 @@ export default function getProtoData( wrapped.__protoData = { uris: [], fields: [], - rels: [] + rels: [], + views: [] } } else { throw new Error(`Object ${wrapped} has no __protoData`) diff --git a/test/integration/Track.ts b/test/integration/Track.ts index c9f90c8..122f890 100644 --- a/test/integration/Track.ts +++ b/test/integration/Track.ts @@ -5,6 +5,7 @@ import { SlothURI, SlothField, SlothRel, + SlothView, belongsToMapper } from '../../src/slothdb' import Artist from './Artist' @@ -17,6 +18,12 @@ export interface TrackSchema { artist: string album: string } + +export enum TrackViews { + ByArtist = 'by_artist', + ByAlbum = 'views/by_album' +} + const artist = belongsToMapper(() => Artist, 'album') const album = belongsToMapper(() => Album, 'artist') @@ -32,6 +39,7 @@ export class TrackEntity extends BaseEntity { @SlothRel({ belongsTo: () => Artist }) artist: string = '' + @SlothView((doc: TrackSchema, emit) => emit(doc.album)) @SlothRel({ belongsTo: () => Album }) album: string = '' @@ -41,4 +49,6 @@ export class TrackEntity extends BaseEntity { } } -export default new SlothDatabase(TrackEntity) +export default new SlothDatabase( + TrackEntity +) diff --git a/test/integration/views.test.ts b/test/integration/views.test.ts new file mode 100644 index 0000000..15aee4f --- /dev/null +++ b/test/integration/views.test.ts @@ -0,0 +1,51 @@ +import Artist from './Artist' +import Track, { TrackViews } from './Track' +import PouchDB from 'pouchdb' +import delay from '../utils/delay' + +PouchDB.plugin(require('pouchdb-adapter-memory')) + +describe('views', () => { + const prefix = Date.now().toString(26) + '_' + + const factory = (name: string) => + new PouchDB(prefix + name, { adapter: 'memory' }) + + beforeAll(async () => { + await Track.put(factory, { + name: 'Palm Trees', + artist: 'library/flatbush-zombies', + album: 'library/flatbush-zombies/betteroffdead', + number: '12' + }) + await Track.put(factory, { + name: 'Not Palm Trees', + artist: 'library/not-flatbush-zombies', + album: 'library/flatbush-zombies/betteroffdead-2', + number: '12' + }) + await Track.put(factory, { + name: 'Mocking Bird', + artist: 'library/eminem', + album: 'library/eminem/some-album-i-forgot', + number: '12' + }) + }) + + test('create views', async () => { + await Track.initSetup(factory) + expect(await factory('tracks').get('_design/views')).toMatchObject({ + views: { by_album: {} } + }) + }) + + test('query by view', async () => { + const docs = await Track.queryDocs( + factory, + TrackViews.ByAlbum, + 'library/flatbush-zombies' + ) + + expect(docs.length).toBe(2) + }) +}) diff --git a/test/unit/decorators/SlothView.test.ts b/test/unit/decorators/SlothView.test.ts new file mode 100644 index 0000000..d865a4e --- /dev/null +++ b/test/unit/decorators/SlothView.test.ts @@ -0,0 +1,37 @@ +import { SlothView } from '../../../src/slothdb' +import emptyProtoData from '../../utils/emptyProtoData' + +test('SlothView - fails without a decorator', () => { + const obj = { foo: 'bar' } + expect(() => SlothView(() => ({}))(obj, 'foo')).toThrowError( + /Required SlothView/ + ) +}) + +test('SlothView - generates a working function for es5 view', () => { + const proto = emptyProtoData({}) + const obj = { __protoData: proto } + + Reflect.defineProperty(obj, 'foo', { get: () => 42 }) + + SlothView(function(doc: { bar: string }, emit) { + emit(doc.bar) + })(obj, 'foo') + + expect(proto.views).toHaveLength(1) + + const { views } = proto + const [{ id, name, code }] = views + + expect(name).toBe('by_foo') + + let fun: Function + + const emit = jest.fn() + + // tslint:disable-next-line:no-eval + eval('fun = ' + code) + fun({ bar: 'barz' }) + + expect(emit).toHaveBeenCalledWith('barz') +}) diff --git a/test/unit/models/SlothDatabase.test.ts b/test/unit/models/SlothDatabase.test.ts index b805fb2..0eae710 100644 --- a/test/unit/models/SlothDatabase.test.ts +++ b/test/unit/models/SlothDatabase.test.ts @@ -1,5 +1,6 @@ import SlothDatabase from '../../../src/models/SlothDatabase' import localPouchFactory from '../../utils/localPouchFactory' +import emptyProtoData from '../../utils/emptyProtoData' test('SlothDatabase#constructor - sets the db name from desc', () => { const db1 = new SlothDatabase({ desc: { name: 'foos' } } as any) @@ -225,3 +226,114 @@ describe('SlothDatabase#changes', () => { expect(cancel).toHaveBeenCalled() }) }) + +describe('SlothDatabase#initSetup', () => { + const proto = Object.assign({}, SlothDatabase.prototype, { + _model: { + prototype: { + __protoData: emptyProtoData({ + views: [ + { + id: 'views', + name: 'by_bar', + code: 'function (doc) { emit(doc.bar); }', + function: () => ({}) + }, + { + id: 'views', + name: 'by_barz', + code: 'function (doc) { emit(doc.barz); }', + function: () => ({}) + } + ] + }) + } + } + }) + + const get = jest.fn() + const put = jest.fn() + + const factory = () => ({ get, put }) + + const sub1 = () => ({}) + const sub2 = () => ({}) + + test(`Creates views if no document found`, async () => { + get.mockRejectedValue(new Error('')) + put.mockResolvedValue(null) + + await SlothDatabase.prototype.initSetup.call(proto, factory) + + expect(get).toHaveBeenCalledTimes(2) + expect(get).toHaveBeenCalledWith('_design/views') + + expect(put).toHaveBeenCalledTimes(2) + expect(put.mock.calls).toEqual([ + [ + { + _id: '_design/views', + views: { + by_bar: { map: 'function (doc) { emit(doc.bar); }' } + } + } + ], + [ + { + _id: '_design/views', + views: { + by_barz: { map: 'function (doc) { emit(doc.barz); }' } + } + } + ] + ]) + }) + + test('Update document if already exists', async () => { + put.mockClear() + get.mockClear() + + get.mockResolvedValue({ + _rev: 'foobar', + views: { + by_foobar: { + map: 'foobar!' + } + } + }) + put.mockResolvedValue(null) + + await SlothDatabase.prototype.initSetup.call(proto, factory) + + expect(get).toHaveBeenCalledTimes(2) + expect(get).toHaveBeenCalledWith('_design/views') + + expect(put).toHaveBeenCalledTimes(2) + expect(put.mock.calls).toEqual([ + [ + { + _rev: 'foobar', + _id: '_design/views', + views: { + by_bar: { map: 'function (doc) { emit(doc.bar); }' }, + by_foobar: { + map: 'foobar!' + } + } + } + ], + [ + { + _rev: 'foobar', + _id: '_design/views', + views: { + by_barz: { map: 'function (doc) { emit(doc.barz); }' }, + by_foobar: { + map: 'foobar!' + } + } + } + ] + ]) + }) +}) diff --git a/test/utils/emptyProtoData.ts b/test/utils/emptyProtoData.ts index c7b49d0..b284d6a 100644 --- a/test/utils/emptyProtoData.ts +++ b/test/utils/emptyProtoData.ts @@ -4,7 +4,8 @@ export default function emptyProtoData(proto: Partial) { const base: ProtoData = { uris: [], fields: [], - rels: [] + rels: [], + views: [] } return Object.assign({}, base, proto) }