Skip to content

Commit

Permalink
feat: add SlothView and SlothIndex
Browse files Browse the repository at this point in the history
  • Loading branch information
vinz243 committed Apr 14, 2018
1 parent 67fe0f2 commit 3a5e0f8
Show file tree
Hide file tree
Showing 11 changed files with 392 additions and 5 deletions.
30 changes: 30 additions & 0 deletions src/decorators/SlothIndex.ts
Original file line number Diff line number Diff line change
@@ -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_<property name>
* @returns the decorator to apply on the field
*/
export default function SlothIndex<S, V extends string = string>(
viewId?: V,
docId?: string
) {
return (target: object, key: string) => {
SlothView(
new Function('doc', 'emit', `emit(doc['${key}'].toString());`) as any,
viewId,
docId
)(target, key)
}
}
45 changes: 45 additions & 0 deletions src/decorators/SlothView.ts
Original file line number Diff line number Diff line change
@@ -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_<property name>
* @returns the decorator to apply on the field
*/
export default function SlothView<S, V extends string = string>(
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
})
}
}
7 changes: 7 additions & 0 deletions src/models/ProtoData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,12 @@ export default interface ProtoData {
key: string
}[]

views: {
id: string
name: string
function: Function
code: string
}[]

rels: (RelationDescriptor & { key: string })[]
}
95 changes: 93 additions & 2 deletions src/models/SlothDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<S, E extends BaseEntity<S>> {
export default class SlothDatabase<
S,
E extends BaseEntity<S>,
V extends string = never
> {
_root: string
/**
*
Expand Down Expand Up @@ -53,6 +57,31 @@ export default class SlothDatabase<S, E extends BaseEntity<S>> {
}
}

/**
* 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<S>,
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
Expand Down Expand Up @@ -141,6 +170,17 @@ export default class SlothDatabase<S, E extends BaseEntity<S>> {
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<S>, props: Partial<S>) {
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
Expand Down Expand Up @@ -205,11 +245,62 @@ export default class SlothDatabase<S, E extends BaseEntity<S>> {
}
}

/**
* Creates view documents (if required)
* @param factory
*/
async initSetup(factory: PouchFactory<S>) {
await this.setupViews(factory)
}

protected getSubscriberFor(factory: PouchFactory<S>) {
return this._subscribers.find(el => el.factory === factory)
}

protected dispatch(action: ChangeAction<S>) {
this._subscribers.forEach(({ sub }) => sub(action))
}

private setupViews(factory: PouchFactory<S>): Promise<void> {
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())
}
}
2 changes: 2 additions & 0 deletions src/slothdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,5 +26,6 @@ export {
BelongsToDescriptor,
HasManyDescriptor,
SlothDatabase,
SlothView,
belongsToMapper
}
3 changes: 2 additions & 1 deletion src/utils/getProtoData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export default function getProtoData(
wrapped.__protoData = {
uris: [],
fields: [],
rels: []
rels: [],
views: []
}
} else {
throw new Error(`Object ${wrapped} has no __protoData`)
Expand Down
12 changes: 11 additions & 1 deletion test/integration/Track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SlothURI,
SlothField,
SlothRel,
SlothView,
belongsToMapper
} from '../../src/slothdb'
import Artist from './Artist'
Expand All @@ -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')

Expand All @@ -32,6 +39,7 @@ export class TrackEntity extends BaseEntity<TrackSchema> {
@SlothRel({ belongsTo: () => Artist })
artist: string = ''

@SlothView((doc: TrackSchema, emit) => emit(doc.album))
@SlothRel({ belongsTo: () => Album })
album: string = ''

Expand All @@ -41,4 +49,6 @@ export class TrackEntity extends BaseEntity<TrackSchema> {
}
}

export default new SlothDatabase<TrackSchema, TrackEntity>(TrackEntity)
export default new SlothDatabase<TrackSchema, TrackEntity, TrackViews>(
TrackEntity
)
51 changes: 51 additions & 0 deletions test/integration/views.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
37 changes: 37 additions & 0 deletions test/unit/decorators/SlothView.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
Loading

0 comments on commit 3a5e0f8

Please sign in to comment.