Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(repository): initial AtomicCrudRepository implementation #1974

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/repository/src/repositories/atomic.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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 FindOrCreateResult<T extends Entity> {
entity: T;
found: boolean;
}

export interface AtomicCrudRepository<T extends Entity, ID>
extends EntityCrudRepository<T, ID> {
/**
* 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<T>,
entity: DataObject<T>,
options?: Options,
): FindOrCreateResult<T>;
}

export class DefaultAtomicCrudRepository<T extends Entity, ID>
extends DefaultCrudRepository<T, ID>
implements AtomicCrudRepository<T, ID> {
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<T>,
entity: DataObject<T>,
options?: AnyObject | undefined,
): Promise<FindOrCreateResult<T>> {
const canRunAtomically =
typeof this.dataSource.connector!.findOrCreate === 'function';
if (!canRunAtomically) {
throw new Error('Method not implemented.');
// FIXME add machine-readable `err.code`
}

const result = await ensurePromise(
this.modelClass.findOrCreate(filter as legacy.Filter, entity, options),
);

return {entity: this.toEntity(result[0]), found: !result[1]};
}
}
1 change: 1 addition & 0 deletions packages/repository/src/repositories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
153 changes: 153 additions & 0 deletions packages/repository/test/unit/repositories/atomic.repository.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// 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,
DefaultCrudRepository,
} 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<Note>) {
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('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/,
);
});
});

context('DefaultCrudRepository', () => {
it('Implements same functionalities as DefaultCrudRepo', async () => {
const repo = new DefaultAtomicCrudRepository(Note, ds);
expect(repo.create).to.equal(DefaultCrudRepository.prototype.create);
expect(repo.createAll).to.equal(
DefaultCrudRepository.prototype.createAll,
);
expect(repo.find).to.equal(DefaultCrudRepository.prototype.find);
expect(repo.findOne).to.equal(DefaultCrudRepository.prototype.findOne);
expect(repo.findById).to.equal(DefaultCrudRepository.prototype.findById);
expect(repo.delete).to.equal(DefaultCrudRepository.prototype.delete);
expect(repo.deleteAll).to.equal(
DefaultCrudRepository.prototype.deleteAll,
);
expect(repo.deleteById).to.equal(
DefaultCrudRepository.prototype.deleteById,
);
expect(repo.update).to.equal(DefaultCrudRepository.prototype.update);
expect(repo.updateAll).to.equal(
DefaultCrudRepository.prototype.updateAll,
);
expect(repo.updateById).to.equal(
DefaultCrudRepository.prototype.updateById,
);
expect(repo.count).to.equal(DefaultCrudRepository.prototype.count);
expect(repo.save).to.equal(DefaultCrudRepository.prototype.save);
expect(repo.replaceById).to.equal(
DefaultCrudRepository.prototype.replaceById,
);
expect(repo.exists).to.equal(DefaultCrudRepository.prototype.exists);
});
});

context('Atomic CRUD operations', () => {
// TODO: how can we test a connector that doesn't have findOrCreate?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how can we test a connector that doesn't have findOrCreate?

See the existing test suite in juggler for inspiration:
https://github.com/strongloop/loopback-datasource-juggler/blob/f0a6bd146b7ef2f987fd974ffdb5906cf6a584db/test/memory.test.js#L904-L923

ds.connector.findOrCreate = false;

I think the following approach is a little bit more clean, but IDK if it works:

ds.connector.findOrCreate = undefined;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thanks for the link @bajtos!

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();
});
});
});