From 1b7548cbf3abdee5d55855f96def545027ea859d Mon Sep 17 00:00:00 2001 From: biniam Date: Mon, 5 Nov 2018 01:44:41 -0500 Subject: [PATCH] feat(repository): initial AtomicCrudRepository implementation --- .../src/repositories/atomic.repository.ts | 69 ++++ packages/repository/src/repositories/index.ts | 1 + .../repositories/atomic.repository.unit.ts | 351 ++++++++++++++++++ 3 files changed, 421 insertions(+) create mode 100644 packages/repository/src/repositories/atomic.repository.ts create mode 100644 packages/repository/test/unit/repositories/atomic.repository.unit.ts diff --git a/packages/repository/src/repositories/atomic.repository.ts b/packages/repository/src/repositories/atomic.repository.ts new file mode 100644 index 000000000000..b341244dec1c --- /dev/null +++ b/packages/repository/src/repositories/atomic.repository.ts @@ -0,0 +1,69 @@ +import {Entity} from '../model'; +import {DataObject, Options, AnyObject} from '../common-types'; +import {Filter} from '../query'; +import {EntityCrudRepository} from './repository'; +import { + DefaultCrudRepository, + juggler, + ensurePromise, +} from './legacy-juggler-bridge'; +import * as assert from 'assert'; +import * as legacy from 'loopback-datasource-juggler'; + +export interface AtomicCrudRepository + extends EntityCrudRepository { + /** + * Finds one record matching the filter object. If not found, creates + * the object using the data provided as second argument. In this sense it is + * the same as `find`, but limited to one object. Returns an object, not + * collection. If you don't provide the filter object argument, it tries to + * locate an existing object that matches the `data` argument. + * + * @param filter Filter object used to match existing model instance + * @param entity Entity to be used for creating a new instance or match + * existing instance if filter is empty + * @param options Options for the operation + * @returns A promise that will be resolve with the created or found instance + * and a 'created' boolean value + */ + findOrCreate( + filter: Filter, + entity: DataObject, + options?: Options, + ): Promise<[T, boolean]>; +} + +export class DefaultAtomicCrudRepository + extends DefaultCrudRepository + implements AtomicCrudRepository { + constructor( + entityClass: typeof Entity & { + prototype: T; + }, + dataSource: juggler.DataSource, + ) { + assert( + dataSource.connector !== undefined, + `Connector instance must exist and support atomic operations`, + ); + super(entityClass, dataSource); + } + + async findOrCreate( + filter: Filter, + entity: DataObject, + options?: AnyObject | undefined, + ): Promise<[T, boolean]> { + if ( + this.dataSource.connector && + typeof this.dataSource.connector.findOrCreate === 'function' + ) { + const result = await ensurePromise( + this.modelClass.findOrCreate(filter as legacy.Filter, entity, options), + ); + return [this.toEntity(result[0]), result[1]]; + } else { + throw new Error('Method not implemented.'); + } + } +} diff --git a/packages/repository/src/repositories/index.ts b/packages/repository/src/repositories/index.ts index 60175cd87ba5..c85c4d267d27 100644 --- a/packages/repository/src/repositories/index.ts +++ b/packages/repository/src/repositories/index.ts @@ -8,3 +8,4 @@ export * from './legacy-juggler-bridge'; export * from './kv.repository.bridge'; export * from './repository'; export * from './constraint-utils'; +export * from './atomic.repository'; diff --git a/packages/repository/test/unit/repositories/atomic.repository.unit.ts b/packages/repository/test/unit/repositories/atomic.repository.unit.ts new file mode 100644 index 000000000000..09f290284ace --- /dev/null +++ b/packages/repository/test/unit/repositories/atomic.repository.unit.ts @@ -0,0 +1,351 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + Entity, + EntityNotFoundError, + juggler, + ModelDefinition, + DefaultAtomicCrudRepository, +} from '../../..'; + +describe('AtomicCrudRepository', () => { + let ds: juggler.DataSource; + + class Note extends Entity { + static definition = new ModelDefinition({ + name: 'Note', + properties: { + title: 'string', + content: 'string', + id: {name: 'id', type: 'number', id: true}, + }, + }); + + title?: string; + content?: string; + id: number; + + constructor(data: Partial) { + super(data); + } + } + + beforeEach(() => { + ds = new juggler.DataSource({ + name: 'db', + connector: 'memory', + }); + }); + + context('constructor', () => { + class ShoppingList extends Entity { + static definition = new ModelDefinition({ + name: 'ShoppingList', + properties: { + id: { + type: 'number', + id: true, + }, + created: { + type: () => Date, + }, + toBuy: { + type: 'array', + itemType: 'string', + }, + toVisit: { + type: Array, + itemType: () => String, + }, + }, + }); + + created: Date; + toBuy: String[]; + toVisit: String[]; + } + + it('converts PropertyDefinition with array type', () => { + const originalPropertyDefinition = Object.assign( + {}, + ShoppingList.definition.properties, + ); + const listDefinition = new DefaultAtomicCrudRepository(ShoppingList, ds) + .modelClass.definition; + const jugglerPropertyDefinition = { + created: {type: Date}, + toBuy: { + type: [String], + }, + toVisit: { + type: [String], + }, + }; + + expect(listDefinition.properties).to.containDeep( + jugglerPropertyDefinition, + ); + expect(ShoppingList.definition.properties).to.containDeep( + originalPropertyDefinition, + ); + }); + + it('throws if a connector instance is not defined for a datasource', () => { + const dsWithoutConnector = new juggler.DataSource({ + name: 'ds2', + }); + let repo; + expect(() => { + repo = new DefaultAtomicCrudRepository( + ShoppingList, + dsWithoutConnector, + ); + }).to.throw( + /Connector instance must exist and support atomic operations/, + ); + }); + + it('shares the backing PersistedModel across repo instances', () => { + const model1 = new DefaultAtomicCrudRepository(Note, ds).modelClass; + const model2 = new DefaultAtomicCrudRepository(Note, ds).modelClass; + + expect(model1 === model2).to.be.true(); + }); + }); + + context('Non atomic CRUD operations', () => { + it('implements Repository.create()', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + const note = await repo.create({title: 't3', content: 'c3'}); + const result = await repo.findById(note.id); + expect(result.toJSON()).to.eql(note.toJSON()); + }); + + it('implements Repository.createAll()', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + const notes = await repo.createAll([ + {title: 't3', content: 'c3'}, + {title: 't4', content: 'c4'}, + ]); + expect(notes.length).to.eql(2); + }); + + it('implements Repository.find()', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + await repo.createAll([ + {title: 't1', content: 'c1'}, + {title: 't2', content: 'c2'}, + ]); + const notes = await repo.find({where: {title: 't1'}}); + expect(notes.length).to.eql(1); + }); + + it('implements Repository.findOne()', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + await repo.createAll([ + {title: 't1', content: 'c1'}, + {title: 't1', content: 'c2'}, + ]); + const note = await repo.findOne({ + where: {title: 't1'}, + order: ['content DESC'], + }); + expect(note).to.not.be.null(); + expect(note && note.title).to.eql('t1'); + expect(note && note.content).to.eql('c2'); + }); + it('returns null if Repository.findOne() does not return a value', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + await repo.createAll([ + {title: 't1', content: 'c1'}, + {title: 't1', content: 'c2'}, + ]); + const note = await repo.findOne({ + where: {title: 't5'}, + order: ['content DESC'], + }); + expect(note).to.be.null(); + }); + + describe('findById', () => { + it('returns the correct instance', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + const note = await repo.create({ + title: 'a-title', + content: 'a-content', + }); + const result = await repo.findById(note.id); + expect(result && result.toJSON()).to.eql(note.toJSON()); + }); + + it('throws when the instance does not exist', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + await expect(repo.findById(999999)).to.be.rejectedWith({ + code: 'ENTITY_NOT_FOUND', + message: 'Entity not found: Note with id 999999', + }); + }); + }); + + it('implements Repository.delete()', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + const note = await repo.create({title: 't3', content: 'c3'}); + + await repo.delete(note); + + const found = await repo.find({where: {id: note.id}}); + expect(found).to.be.empty(); + }); + + it('implements Repository.deleteById()', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + const note = await repo.create({title: 't3', content: 'c3'}); + + await repo.deleteById(note.id); + + const found = await repo.find({where: {id: note.id}}); + expect(found).to.be.empty(); + }); + + it('throws EntityNotFoundError when deleting an unknown id', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + await expect(repo.deleteById(99999)).to.be.rejectedWith( + EntityNotFoundError, + ); + }); + + it('implements Repository.deleteAll()', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + await repo.create({title: 't3', content: 'c3'}); + await repo.create({title: 't4', content: 'c4'}); + const result = await repo.deleteAll({title: 't3'}); + expect(result.count).to.eql(1); + }); + + it('implements Repository.updateById()', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + const note = await repo.create({title: 't3', content: 'c3'}); + + const id = note.id; + const delta = {content: 'c4'}; + await repo.updateById(id, delta); + + const updated = await repo.findById(id); + expect(updated.toJSON()).to.eql(Object.assign(note.toJSON(), delta)); + }); + + it('throws EntityNotFound error when updating an unknown id', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + await expect(repo.updateById(9999, {title: 't4'})).to.be.rejectedWith( + EntityNotFoundError, + ); + }); + + it('implements Repository.updateAll()', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + await repo.create({title: 't3', content: 'c3'}); + await repo.create({title: 't4', content: 'c4'}); + const result = await repo.updateAll({content: 'c5'}, {}); + expect(result.count).to.eql(2); + const notes = await repo.find({where: {title: 't3'}}); + expect(notes[0].content).to.eql('c5'); + }); + + it('implements Repository.updateAll() without a where object', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + await repo.create({title: 't3', content: 'c3'}); + await repo.create({title: 't4', content: 'c4'}); + const result = await repo.updateAll({content: 'c5'}); + expect(result.count).to.eql(2); + const notes = await repo.find(); + const titles = notes.map(n => `${n.title}:${n.content}`); + expect(titles).to.deepEqual(['t3:c5', 't4:c5']); + }); + + it('implements Repository.count()', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + await repo.create({title: 't3', content: 'c3'}); + await repo.create({title: 't4', content: 'c4'}); + const result = await repo.count(); + expect(result.count).to.eql(2); + }); + + it('implements Repository.save() without id', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + const note = await repo.save(new Note({title: 't3', content: 'c3'})); + const result = await repo.findById(note!.id); + expect(result.toJSON()).to.eql(note!.toJSON()); + }); + + it('implements Repository.save() with id', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + const note1 = await repo.create({title: 't3', content: 'c3'}); + note1.content = 'c4'; + const note = await repo.save(note1); + const result = await repo.findById(note!.id); + expect(result.toJSON()).to.eql(note1.toJSON()); + }); + + it('implements Repository.replaceById()', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + const note = await repo.create({title: 't3', content: 'c3'}); + await repo.replaceById(note.id, {title: 't4', content: undefined}); + const result = await repo.findById(note.id); + expect(result.toJSON()).to.eql({ + id: note.id, + title: 't4', + content: undefined, + }); + }); + + it('throws EntityNotFound error when replacing an unknown id', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + await expect(repo.replaceById(9999, {title: 't4'})).to.be.rejectedWith( + EntityNotFoundError, + ); + }); + + it('implements Repository.exists()', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + const note1 = await repo.create({title: 't3', content: 'c3'}); + const ok = await repo.exists(note1.id); + expect(ok).to.be.true(); + }); + }); + + context('Atomic CRUD operations', () => { + // TODO: how can we test a connector that doesn't have findOrCreate? + it('uses findOrCreate to create an instance if not found', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + const result = await repo.findOrCreate( + {where: {title: 't1'}}, + {title: 'new t1', content: 'new c1'}, + ); + expect(result[0].toJSON()).to.containEql({ + title: 'new t1', + content: 'new c1', + }); + expect(result[1]).to.be.true(); + }); + it('uses findOrCreate to find an existing instance', async () => { + const repo = new DefaultAtomicCrudRepository(Note, ds); + await repo.createAll([ + {title: 't1', content: 'c1'}, + {title: 't2', content: 'c2'}, + ]); + const result = await repo.findOrCreate( + {where: {title: 't1'}}, + {title: 'new t1', content: 'new c1'}, + ); + expect(result[0].toJSON()).to.containEql({ + title: 't1', + content: 'c1', + }); + expect(result[1]).to.be.false(); + }); + }); +});