diff --git a/src/decorators/SlothEntity.ts b/src/decorators/SlothEntity.ts index 57d2614..eecbc02 100644 --- a/src/decorators/SlothEntity.ts +++ b/src/decorators/SlothEntity.ts @@ -4,9 +4,27 @@ import StaticData from '../models/StaticData' import PouchFactory from '../models/PouchFactory' import EntityConstructor from '../helpers/EntityConstructor' import getProtoData from '../utils/getProtoData' +import ProtoData from '../models/ProtoData' const slug = require('limax') +function mapPropsOrDocToDocument({ fields }: ProtoData, data: any) { + return fields.reduce( + (props, { key, docKey }) => { + if (!(key in data) && !(docKey in data)) { + return props + } + if (key in data && docKey in data && key !== docKey) { + throw new Error(`Both '${key}' and '${docKey}' exist on ${data}`) + } + return Object.assign({}, props, { + [docKey]: key in data ? data[key] : data[docKey] + }) + }, + {} as any + ) +} + /** * This decorator is used to mark classes that will be an entity, a document * This function, by extending the constructor and defining this.sloth property @@ -44,7 +62,7 @@ export default function SlothEntity(name: string) { this.sloth = { name, updatedProps: {}, - props: idOrProps, + props: mapPropsOrDocToDocument(getProtoData(this), idOrProps), docId: idOrProps._id, factory, slug diff --git a/src/decorators/SlothField.ts b/src/decorators/SlothField.ts index 0fdf3ee..ea9e44f 100644 --- a/src/decorators/SlothField.ts +++ b/src/decorators/SlothField.ts @@ -10,9 +10,12 @@ import getProtoData from '../utils/getProtoData' * - mutating the value will update it in updatedProps * - accessing the value will first look into updatedProps, then props and then default values * @typeparam T the value type + * @param docKeyName specifies the document key name, default to prop key name */ -export default function SlothField() { +export default function SlothField(docKeyName?: string) { return function(this: any, target: object, key: string) { + const docKey = docKeyName || key + const desc = Reflect.getOwnPropertyDescriptor(target, key) let defaultValue: T @@ -24,18 +27,18 @@ export default function SlothField() { const data = getProtoData(target, true) - data.fields.push({ key }) + data.fields.push({ key, docKey }) Reflect.deleteProperty(target, key) Reflect.defineProperty(target, key, { get: function(): T { const { updatedProps, props } = getSlothData(this) - if (key in updatedProps) { - return (updatedProps as any)[key] + if (docKey in updatedProps) { + return (updatedProps as any)[docKey] } - if (key in props) { - return (props as any)[key] + if (docKey in props) { + return (props as any)[docKey] } return defaultValue }, @@ -48,7 +51,7 @@ export default function SlothField() { return } - Object.assign(getSlothData(this).updatedProps, { [key]: value }) + Object.assign(getSlothData(this).updatedProps, { [docKey]: value }) } }) } diff --git a/src/decorators/SlothRel.ts b/src/decorators/SlothRel.ts index 7dd80ea..9711753 100644 --- a/src/decorators/SlothRel.ts +++ b/src/decorators/SlothRel.ts @@ -27,7 +27,7 @@ export default function SlothRel(rel: RelationDescriptor) { const { fields, rels } = getProtoData(target, true) - fields.push({ key }) + fields.push({ key, docKey: key }) rels.push({ ...rel, key }) Reflect.deleteProperty(target, key) diff --git a/src/decorators/SlothURI.ts b/src/decorators/SlothURI.ts index d35dfce..50cf2de 100644 --- a/src/decorators/SlothURI.ts +++ b/src/decorators/SlothURI.ts @@ -40,7 +40,7 @@ export default function SlothURI(prefix: string, ...propsKeys: (keyof S)[]) { propsKeys }) - fields.push({ key }) + fields.push({ key, docKey: key }) Reflect.defineProperty(target, key, { get: function() { diff --git a/src/models/BaseEntity.ts b/src/models/BaseEntity.ts index 3591cfe..d8b5c38 100644 --- a/src/models/BaseEntity.ts +++ b/src/models/BaseEntity.ts @@ -24,6 +24,32 @@ export default class BaseEntity { return Object.keys(updatedProps).length > 0 || docId == null } + /** + * Returns a list of props following the entity schema + */ + getProps(): S { + const { fields } = getProtoData(this) + return fields.reduce( + (props, { key }) => { + return Object.assign({}, props, { [key]: (this as any)[key] }) + }, + {} as any + ) + } + + /** + * Returns a list of props mapped with docKey + */ + getDocument() { + const { fields } = getProtoData(this) + return fields.reduce( + (props, { key, docKey }) => { + return Object.assign({}, props, { [docKey]: (this as any)[key] }) + }, + {} as any + ) + } + /** * Saves document to database. If the document doesn't exist, * create it. If it exists, update it. If the _id was changed @@ -39,15 +65,10 @@ export default class BaseEntity { */ async save(): Promise { const { fields } = getProtoData(this, false) - - const props: S = fields - .map(({ key }) => { - return { [key]: (this as any)[key] } - }) - .reduce((acc, val) => ({ ...acc, ...val }), {}) as any + const doc = this.getDocument() if (!this.isDirty()) { - return props as S + return doc } const { factory, name, docId } = getSlothData(this) @@ -56,11 +77,11 @@ export default class BaseEntity { try { const { _rev } = await db.get(this._id) - const { rev } = await db.put(Object.assign({}, props, { _rev })) + const { rev } = await db.put(Object.assign({}, doc, { _rev })) getSlothData(this).docId = this._id - return Object.assign({}, props, { _rev: rev }) + return Object.assign({}, doc, { _rev: rev }) } catch (err) { // Then document was not found @@ -72,11 +93,11 @@ export default class BaseEntity { getSlothData(this).docId = this._id } - const { rev, id } = await db.put(props) + const { rev, id } = await db.put(doc) getSlothData(this).docId = this._id - return Object.assign({}, props, { _rev: rev }) + return Object.assign({}, doc, { _rev: rev }) } throw err diff --git a/src/models/ProtoData.ts b/src/models/ProtoData.ts index cb607cb..837eb6b 100644 --- a/src/models/ProtoData.ts +++ b/src/models/ProtoData.ts @@ -25,6 +25,7 @@ export default interface ProtoData { */ fields: { key: string + docKey: string }[] views: { diff --git a/test/integration/docKeys.test.ts b/test/integration/docKeys.test.ts new file mode 100644 index 0000000..9bdb566 --- /dev/null +++ b/test/integration/docKeys.test.ts @@ -0,0 +1,83 @@ +import PouchDB from 'pouchdb' +import { + SlothDatabase, + SlothURI, + BaseEntity, + SlothEntity, + SlothField +} from '../../src/slothdb' + +PouchDB.plugin(require('pouchdb-adapter-memory')) + +describe('docKeys', () => { + let prefix: string + + const factory = (pre: string) => (name: string) => + new PouchDB(pre + name, { adapter: 'memory' }) + + interface IFoo { + _id: string + name: string + bar: string + } + + @SlothEntity('foos') + class FooEntity extends BaseEntity { + @SlothURI('foos', 'name', 'bar') + _id: string + @SlothField() name: string + @SlothField('barz') bar: string + } + + const Foo = new SlothDatabase(FooEntity) + + beforeEach(() => { + prefix = '__' + Date.now().toString(16) + '_' + }) + + test('getProps maps props', () => { + const doc = Foo.create(factory(prefix), { name: 'Foo Bar', bar: 'barz' }) + + expect(doc.getProps()).toEqual({ + _id: 'foos/foo-bar/barz', + name: 'Foo Bar', + bar: 'barz' + }) + }) + + test('getProps maps props from doc', () => { + const doc = Foo.create(factory(prefix), { + name: 'Foo Bar', + barz: 'barz' + } as any) + + expect(doc.getProps()).toEqual({ + _id: 'foos/foo-bar/barz', + name: 'Foo Bar', + bar: 'barz' + }) + }) + + test('getDocument maps document', () => { + const doc = Foo.create(factory(prefix), { name: 'Foo Bar', bar: 'barz' }) + + expect(doc.getDocument()).toEqual({ + _id: 'foos/foo-bar/barz', + name: 'Foo Bar', + barz: 'barz' + }) + }) + + test('getDocument maps document from doc', () => { + const doc = Foo.create(factory(prefix), { + name: 'Foo Bar', + barz: 'barz' + } as any) + + expect(doc.getDocument()).toEqual({ + _id: 'foos/foo-bar/barz', + name: 'Foo Bar', + barz: 'barz' + }) + }) +}) diff --git a/test/unit/decorators/SlothEntity.test.ts b/test/unit/decorators/SlothEntity.test.ts index 2f7dbc3..3719d64 100644 --- a/test/unit/decorators/SlothEntity.test.ts +++ b/test/unit/decorators/SlothEntity.test.ts @@ -1,5 +1,6 @@ import SlothEntity from '../../../src/decorators/SlothEntity' import localPouchFactory from '../../utils/localPouchFactory' +import emptyProtoData from '../../utils/emptyProtoData' test('SlothEntity - attaches a sloth object to class', () => { // tslint:disable-next-line:no-empty @@ -31,7 +32,9 @@ test('SlothEntity - set the props when props are passed', () => { const constr = () => {} const wrapper = SlothEntity('foo')(constr as any) - const context: any = {} + const context: any = { + __protoData: emptyProtoData({ fields: [{ key: 'foo', docKey: 'foo' }] }) + } wrapper.call(context, localPouchFactory, { foo: 'bar' }) @@ -40,12 +43,61 @@ test('SlothEntity - set the props when props are passed', () => { expect(context.sloth.props.foo).toBe('bar') expect(context.sloth.docId).toBeUndefined() }) +test('SlothEntity - can use keys', () => { + // tslint:disable-next-line:no-empty + const constr = () => {} + + const wrapper = SlothEntity('foo')(constr as any) + const context: any = { + __protoData: emptyProtoData({ fields: [{ key: 'foo', docKey: 'barz' }] }) + } + + wrapper.call(context, localPouchFactory, { foo: 'bar' }) + + expect(context.sloth).toBeDefined() + expect(context.sloth.name).toBe('foo') + expect(context.sloth.props.barz).toBe('bar') + expect(context.sloth.docId).toBeUndefined() +}) +test('SlothEntity - can use docKeys', () => { + // tslint:disable-next-line:no-empty + const constr = () => {} + + const wrapper = SlothEntity('foo')(constr as any) + const context: any = { + __protoData: emptyProtoData({ fields: [{ key: 'foo', docKey: 'barz' }] }) + } + + wrapper.call(context, localPouchFactory, { barz: 'bar' }) + + expect(context.sloth).toBeDefined() + expect(context.sloth.name).toBe('foo') + expect(context.sloth.props.barz).toBe('bar') + expect(context.sloth.docId).toBeUndefined() +}) +test('SlothEntity - throws an error when both keys and docKeys maps are passed', () => { + // tslint:disable-next-line:no-empty + const constr = () => {} + + const wrapper = SlothEntity('foo')(constr as any) + const context: any = { + __protoData: emptyProtoData({ fields: [{ key: 'foo', docKey: 'barz' }] }) + } + + expect(() => + wrapper.call(context, localPouchFactory, { barz: 'bar', foo: 'barz' }) + ).toThrowError(/Both 'foo' and 'barz' exist/) +}) test('SlothEntity - eventually set docId when props are passed with _id', () => { // tslint:disable-next-line:no-empty const constr = () => {} const wrapper = SlothEntity('foo')(constr as any) - const context: any = {} + const context: any = { + __protoData: emptyProtoData({ + fields: [{ key: 'foo', docKey: 'foo' }, { key: '_id', docKey: '_id' }] + }) + } wrapper.call(context, localPouchFactory, { _id: 'foobar', foo: 'bar' }) diff --git a/test/unit/decorators/SlothURI.test.ts b/test/unit/decorators/SlothURI.test.ts index ebf8744..825e5cf 100644 --- a/test/unit/decorators/SlothURI.test.ts +++ b/test/unit/decorators/SlothURI.test.ts @@ -46,7 +46,7 @@ test('SlothURI - pushes to uris', () => { } ]) - expect(__protoData.fields).toEqual([{ key: '_id' }]) + expect(__protoData.fields).toEqual([{ key: '_id', docKey: '_id' }]) }) test('SlothURI - throws if on top of another decorator', () => { diff --git a/test/unit/models/BaseEntity.test.ts b/test/unit/models/BaseEntity.test.ts index 93378bb..4977562 100644 --- a/test/unit/models/BaseEntity.test.ts +++ b/test/unit/models/BaseEntity.test.ts @@ -30,16 +30,20 @@ test('BaseEntity#save immediately returns props if not dirty', async () => { expect( await BaseEntity.prototype.save.call({ isDirty, + getDocument: () => 'foo', __protoData: { fields: [{ key: 'foo' }] }, foo: 'bar' }) - ).toEqual({ foo: 'bar' }) + ).toEqual('foo') expect(isDirty).toHaveBeenCalled() }) test('BaseEntity#save create doc if does not exist', async () => { const isDirty = jest.fn().mockReturnValue(true) + const getDocument = jest + .fn() + .mockReturnValue({ _id: 'foos/bar', name: 'bar' }) const get = jest.fn().mockRejectedValue({ name: 'not_found' }) const put = jest.fn().mockResolvedValue({ rev: 'revision' }) @@ -48,6 +52,7 @@ test('BaseEntity#save create doc if does not exist', async () => { const { _rev } = await BaseEntity.prototype.save.call({ isDirty, + getDocument, __protoData: { fields: [{ key: '_id' }, { key: 'name' }] }, @@ -68,6 +73,7 @@ test('BaseEntity#save create doc if does not exist', async () => { test('BaseEntity#save remove previous doc', async () => { const isDirty = jest.fn().mockReturnValue(true) + const { getDocument } = BaseEntity.prototype const get = jest .fn() @@ -81,8 +87,9 @@ test('BaseEntity#save remove previous doc', async () => { const { _rev } = await BaseEntity.prototype.save.call({ isDirty, + getDocument, __protoData: { - fields: [{ key: '_id' }, { key: 'name' }] + fields: [{ key: '_id', docKey: '_id' }, { key: 'name', docKey: 'name' }] }, sloth: { factory, @@ -108,6 +115,7 @@ test('BaseEntity#save remove previous doc', async () => { test('BaseEntity#save throws error if not not_found', async () => { const isDirty = jest.fn().mockReturnValue(true) + const { getDocument } = BaseEntity.prototype const get = jest.fn().mockRejectedValue(new Error('foo_error')) const put = jest.fn().mockResolvedValue(null) @@ -116,6 +124,7 @@ test('BaseEntity#save throws error if not not_found', async () => { await expect( BaseEntity.prototype.save.apply({ + getDocument, isDirty, __protoData: { fields: [{ key: '_id' }, { key: 'name' }] @@ -303,3 +312,41 @@ test('BaseEntity#removeRelations remove parent if no child', async () => { expect(remove).toHaveBeenCalled() expect(belongsTo).toHaveBeenCalled() }) + +test('BaseEntity#getProps returns props', () => { + const doc = BaseEntity.prototype.getProps.call({ + __protoData: { + fields: [{ key: 'name' }, { key: '_id' }, { key: 'foo' }] + }, + name: 'John', + _id: 'john', + foo: 'bar' + }) + + expect(doc).toEqual({ + name: 'John', + _id: 'john', + foo: 'bar' + }) +}) + +test('BaseEntity#getDocument returns props', () => { + const doc = BaseEntity.prototype.getDocument.call({ + __protoData: { + fields: [ + { key: 'name', docKey: 'not_name' }, + { key: '_id', docKey: '_id' }, + { key: 'foo', docKey: 'bar' } + ] + }, + name: 'John', + _id: 'john', + foo: 'bar' + }) + + expect(doc).toEqual({ + not_name: 'John', + _id: 'john', + bar: 'bar' + }) +})