From 980da72b538424056e54359c133cd6e0791147ac Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 28 Nov 2017 11:31:28 -0800 Subject: [PATCH 1/3] feat(repository): Add builders for Filter and Where --- .../src/loopback-datasource-juggler.ts | 18 +- packages/repository/src/query.ts | 324 +++++++++++++++++- .../test/unit/query/query-builder.ts | 275 +++++++++++++++ 3 files changed, 598 insertions(+), 19 deletions(-) create mode 100644 packages/repository/test/unit/query/query-builder.ts diff --git a/packages/repository/src/loopback-datasource-juggler.ts b/packages/repository/src/loopback-datasource-juggler.ts index 7d17aa8cf3a5..8ce1fef67d64 100644 --- a/packages/repository/src/loopback-datasource-juggler.ts +++ b/packages/repository/src/loopback-datasource-juggler.ts @@ -202,7 +202,7 @@ export declare namespace juggler { eq?: any; neq?: any; gt?: any; - get?: any; + gte?: any; lt?: any; lte?: any; inq?: any[]; @@ -221,18 +221,6 @@ export declare namespace juggler { [property: string]: Condition | any; // Other criteria } - /** - * Order by direction - */ - export type Direction = 'ASC' | 'DESC'; - - /** - * Order by - */ - export interface Order { - [property: string]: Direction; - } - /** * Selection of fields */ @@ -245,7 +233,7 @@ export declare namespace juggler { */ export interface Inclusion { relation: string; - scope: Filter; + scope?: Filter; } /** @@ -254,7 +242,7 @@ export declare namespace juggler { export interface Filter { where?: Where; fields?: Fields; - order?: Order[]; + order?: string[]; limit?: number; skip?: number; offset?: number; diff --git a/packages/repository/src/query.ts b/packages/repository/src/query.ts index a6a1fa5ca460..bd216d68b9e3 100644 --- a/packages/repository/src/query.ts +++ b/packages/repository/src/query.ts @@ -1,3 +1,6 @@ +import {relation, AnyObject} from '../index'; +import * as assert from 'assert'; + // Copyright IBM Corp. 2017. All Rights Reserved. // Node module: @loopback/repository // This file is licensed under the MIT License. @@ -29,7 +32,7 @@ export interface Condition { eq?: any; neq?: any; gt?: any; - get?: any; + gte?: any; lt?: any; lte?: any; inq?: any[]; @@ -56,7 +59,7 @@ export interface Where { /** * Order by direction */ -export type Direction = 'ASC' | 'DESC'; +export type Direction = 'ASC' | 'DESC' | 1 | -1; /** * Order by @@ -88,7 +91,7 @@ export interface Fields { */ export interface Inclusion { relation: string; - scope: Filter; + scope?: Filter; } /** @@ -106,7 +109,7 @@ export interface Filter { /** * Sorting order for matched entities */ - order?: Order[]; + order?: string[]; /** * Maximum number of entities */ @@ -124,3 +127,316 @@ export interface Filter { */ include?: Inclusion[]; } + +/** + * A builder for Where object + */ +export class WhereBuilder { + where: Where; + + constructor(w?: Where) { + this.where = w || {}; + } + + private add(w: Where): this { + for (const k of Object.keys(w)) { + if (k in this.where) { + // Found conflicting keys, create an `and` operator to join the existing + // conditions with the new one + this.where = {and: [this.where, w]}; + return this; + } + } + // Merge the where items + this.where = Object.assign(this.where, w); + return this; + } + + /** + * Add an `and` clause. + * @param w One or more where objects + */ + and(...w: (Where | Where[])[]): this { + let clauses: Where[] = []; + w.forEach(where => { + clauses = clauses.concat(Array.isArray(where) ? where : [where]); + }); + return this.add({and: clauses}); + } + + /** + * Add an `or` clause. + * @param w One or more where objects + */ + or(...w: (Where | Where[])[]): this { + let clauses: Where[] = []; + w.forEach(where => { + clauses = clauses.concat(Array.isArray(where) ? where : [where]); + }); + return this.add({or: clauses}); + } + + /** + * Add an `=` condition + * @param key Property name + * @param val Property value + */ + eq(key: string, val: any): this { + return this.add({[key]: val}); + } + + /** + * Add a `!=` condition + * @param key Property name + * @param val Property value + */ + neq(key: string, val: any): this { + return this.add({[key]: {neq: val}}); + } + + /** + * Add a `>` condition + * @param key Property name + * @param val Property value + */ + gt(key: string, val: any): this { + return this.add({[key]: {gt: val}}); + } + + /** + * Add a `>=` condition + * @param key Property name + * @param val Property value + */ + gte(key: string, val: any): this { + return this.add({[key]: {gte: val}}); + } + + /** + * Add a `<` condition + * @param key Property name + * @param val Property value + */ + lt(key: string, val: any): this { + return this.add({[key]: {lt: val}}); + } + + /** + * Add a `<=` condition + * @param key Property name + * @param val Property value + */ + lte(key: string, val: any): this { + return this.add({[key]: {lte: val}}); + } + + /** + * Add a `inq` condition + * @param key Property name + * @param val An array of property values + */ + inq(key: string, val: any[]): this { + return this.add({[key]: {inq: val}}); + } + + /** + * Add a `between` condition + * @param key Property name + * @param val1 Property value lower bound + * @param val2 Property value upper bound + */ + between(key: string, val1: any, val2: any): this { + return this.add({[key]: {between: [val1, val2]}}); + } + + /** + * Add a `exists` condition + * @param key Property name + * @param val Exists or not + */ + exists(key: string, val?: boolean): this { + return this.add({[key]: {exists: !!val || val == null}}); + } + + /** + * Get the where object + */ + build() { + return this.where; + } +} + +/** + * A builder for Filter + */ +export class FilterBuilder { + filter: Filter; + + constructor(f?: Filter) { + this.filter = f || {}; + } + + /** + * Set `limit` + * @param limit Maximum number of records to be returned + */ + limit(limit: number): this { + assert(limit >= 1, `Limit ${limit} must a positive number`); + this.filter.limit = limit; + return this; + } + + /** + * Set `offset` + * @param offset Offset of the number of records to be returned + */ + offset(offset: number): this { + this.filter.offset = offset; + return this; + } + + /** + * Alias to `offset` + * @param skip + */ + skip(skip: number): this { + return this.offset(skip); + } + + /** + * Describe what fields to be included/excluded + * @param f A field name to be included, an array of field names to be + * included, or an Fields object for the inclusion/exclusion + */ + fields(...f: (Fields | string[] | string)[]): this { + if (!this.filter.fields) { + this.filter.fields = {}; + } + f.forEach(field => { + if (Array.isArray(field)) { + field.forEach(i => (this.filter.fields![i] = true)); + } else if (typeof field === 'string') { + this.filter.fields![field] = true; + } else { + Object.assign(this.filter.fields, field); + } + }); + return this; + } + + private validateOrder(order: string) { + assert(order.match(/^[^\s]+( (ASC|DESC))?$/), 'Invalid order: ' + order); + } + + /** + * Describe the sorting order + * @param f A field name with optional direction, an array of field names, + * or an Order object for the field/direction pairs + */ + order(...o: (string | string[] | Order)[]): this { + if (!this.filter.order) { + this.filter.order = []; + } + o.forEach(order => { + if (typeof order === 'string') { + this.validateOrder(order); + this.filter.order!.push(order); + return this; + } + if (Array.isArray(order)) { + order.forEach(this.validateOrder); + this.filter.order = this.filter.order!.concat(order); + return this; + } + for (const i in order) { + let dir: string; + if (order[i] === 1) { + dir = 'ASC'; + } else if (order[i] === -1) { + dir = 'DESC'; + } else { + dir = order[i]; + } + this.filter.order!.push(`${i} ${dir}`); + } + }); + return this; + } + + /** + * Declare `include` + * @param i A relation name, an array of relation names, or an `Inclusion` + * object for the relation/scope definitions + */ + include(...i: (string | string[] | Inclusion)[]): this { + if (!this.filter.include) { + this.filter.include = []; + } + i.forEach(include => { + if (typeof include === 'string') { + this.filter.include!.push({relation: include}); + } else if (Array.isArray(include)) { + include.forEach(inc => this.filter.include!.push({relation: inc})); + } else { + this.filter.include!.push(include); + } + }); + return this; + } + + /** + * Declare a where clause + * @param w Where object + */ + where(w: Where): this { + this.filter.where = w; + return this; + } + + /** + * Return the filter object + */ + build() { + return this.filter; + } +} + +/** + * Get nested properties by path + * @param value Value of an object + * @param path Path to the property + */ +function getDeepProperty(value: AnyObject, path: string): any { + const props = path.split('.'); + for (const p of props) { + value = value[p]; + if (value === undefined || value === null) { + return value; + } + } + return value; +} + +export function filterTemplate(strings: TemplateStringsArray, ...keys: any[]) { + return function filter(ctx: AnyObject) { + const tokens = [strings[0]]; + keys.forEach((key, i) => { + if ( + typeof key === 'object' || + typeof key === 'boolean' || + typeof key === 'number' + ) { + tokens.push(JSON.stringify(key), strings[i + 1]); + return; + } + const value = getDeepProperty(ctx, key); + tokens.push(JSON.stringify(value), strings[i + 1]); + }); + const result = tokens.join(''); + try { + return JSON.parse(result); + } catch (e) { + throw new Error('Invalid JSON: ' + result); + } + }; +} diff --git a/packages/repository/test/unit/query/query-builder.ts b/packages/repository/test/unit/query/query-builder.ts new file mode 100644 index 000000000000..3ac825fbca91 --- /dev/null +++ b/packages/repository/test/unit/query/query-builder.ts @@ -0,0 +1,275 @@ +// Copyright IBM Corp. 2013,2017. 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 { + FilterBuilder, + Filter, + WhereBuilder, + Where, + filterTemplate, +} from '../../../'; + +describe('WhereBuilder', () => { + it('builds where object', () => { + const whereBuilder = new WhereBuilder(); + const where = whereBuilder + .eq('a', 1) + .gt('b', 2) + .lt('c', 2) + .eq('x', 'x') + .build(); + expect(where).to.eql({a: 1, b: {gt: 2}, c: {lt: 2}, x: 'x'}); + }); + + it('builds where object with multiple clauses using the same key', () => { + const whereBuilder = new WhereBuilder(); + const where = whereBuilder + .gt('a', 2) + .lt('a', 4) + .build(); + expect(where).to.eql({and: [{a: {gt: 2}}, {a: {lt: 4}}]}); + }); + + it('builds where object with inq', () => { + const whereBuilder = new WhereBuilder(); + const where = whereBuilder + .inq('x', [1, 2, 3]) + .inq('y', ['a', 'b']) + .build(); + expect(where).to.eql({x: {inq: [1, 2, 3]}, y: {inq: ['a', 'b']}}); + }); + + it('builds where object with between', () => { + const whereBuilder = new WhereBuilder(); + const where = whereBuilder + .between('x', 1, 2) + .between('y', 'a', 'b') + .build(); + expect(where).to.eql({x: {between: [1, 2]}, y: {between: ['a', 'b']}}); + }); + + it('builds where object with or', () => { + const whereBuilder = new WhereBuilder(); + const where = whereBuilder + .eq('a', 1) + .gt('b', 2) + .lt('c', 2) + .or({x: 'x'}, {y: {gt: 1}}) + .build(); + expect(where).to.eql({ + a: 1, + b: {gt: 2}, + c: {lt: 2}, + or: [{x: 'x'}, {y: {gt: 1}}], + }); + }); + + it('builds where object with and', () => { + const whereBuilder = new WhereBuilder(); + const where = whereBuilder + .eq('a', 1) + .gt('b', 2) + .lt('c', 2) + .and({x: 'x'}, {y: {gt: 1}}) + .build(); + expect(where).to.eql({ + a: 1, + b: {gt: 2}, + c: {lt: 2}, + and: [{x: 'x'}, {y: {gt: 1}}], + }); + }); + + it('builds where object with existing and', () => { + const whereBuilder = new WhereBuilder(); + const where = whereBuilder + .eq('a', 1) + .and({x: 'x'}, {y: {gt: 1}}) + .and({b: 'b'}, {c: {lt: 1}}) + .build(); + expect(where).to.eql({ + and: [ + { + a: 1, + and: [{x: 'x'}, {y: {gt: 1}}], + }, + { + and: [{b: 'b'}, {c: {lt: 1}}], + }, + ], + }); + }); + + it('builds where object from an existing one', () => { + const whereBuilder = new WhereBuilder({y: 'y'}); + const where = whereBuilder + .eq('a', 1) + .gt('b', 2) + .lt('c', 2) + .eq('x', 'x') + .build(); + expect(where).to.eql({y: 'y', a: 1, b: {gt: 2}, c: {lt: 2}, x: 'x'}); + }); +}); + +describe('FilterBuilder', () => { + it('builds a filter object with field names', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.fields('a', 'b', 'c'); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + fields: { + a: true, + b: true, + c: true, + }, + }); + }); + + it('builds a filter object with field object', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.fields({a: true, b: false}); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + fields: { + a: true, + b: false, + }, + }); + }); + + it('builds a filter object with mixed field names and objects', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.fields({a: true, b: false}, 'c', ['d', 'e']); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + fields: { + a: true, + b: false, + c: true, + d: true, + e: true, + }, + }); + }); + + it('builds a filter object with limit/offset', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.limit(10).offset(5); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + limit: 10, + offset: 5, + }); + }); + + it('builds a filter object with limit/skip', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.limit(10).skip(5); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + limit: 10, + offset: 5, + }); + }); + + it('validates limit', () => { + expect(() => { + const filterBuilder = new FilterBuilder(); + filterBuilder.limit(-10).offset(5); + }).to.throw(/Limit \-10 must a positive number/); + }); + + it('builds a filter object with order names', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.order('a', 'b', 'c'); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + order: ['a', 'b', 'c'], + }); + }); + + it('builds a filter object with order object', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.order({a: 1, b: -1}); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + order: ['a ASC', 'b DESC'], + }); + }); + + it('builds a filter object with mixed field names and objects', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.order({a: 'ASC', b: 'DESC'}, 'c DESC', ['d', 'e']); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + order: ['a ASC', 'b DESC', 'c DESC', 'd', 'e'], + }); + }); + + it('validates order', () => { + expect(() => { + const filterBuilder = new FilterBuilder(); + filterBuilder.order('a x'); + }).to.throw(/Invalid order/); + }); + + it('builds a filter object with where', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.where({x: 1, and: [{a: {gt: 2}}, {b: 2}]}); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + where: {x: 1, and: [{a: {gt: 2}}, {b: 2}]}, + }); + }); + + it('builds a filter object with included relation names', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.include('orders', 'friends'); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + include: [{relation: 'orders'}, {relation: 'friends'}], + }); + }); + + it('builds a filter object with included an array of relation names', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.include(['orders', 'friends']); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + include: [{relation: 'orders'}, {relation: 'friends'}], + }); + }); + + it('builds a filter object with inclusion objects', () => { + const filterBuilder = new FilterBuilder(); + filterBuilder.include( + {relation: 'orders'}, + {relation: 'friends', scope: {where: {name: 'ray'}}}, + ); + const filter = filterBuilder.build(); + expect(filter).to.eql({ + include: [ + {relation: 'orders'}, + {relation: 'friends', scope: {where: {name: 'ray'}}}, + ], + }); + }); +}); + +describe('FilterTemplate', () => { + it('builds filter object', () => { + const filter = filterTemplate`{"limit": ${'limit'}, + "where": {${'key'}: ${'value'}}}`; + const result = filter({limit: 10, key: 'name', value: 'John'}); + expect(result).to.eql({ + limit: 10, + where: { + name: 'John', + }, + }); + }); +}); From 26d2975f9e0fece7e81c991602a87a1276854773 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 29 Nov 2017 15:56:57 -0800 Subject: [PATCH 2/3] feat(repository): Add execute method to Repository --- packages/repository/src/connector.ts | 7 +++++ .../repository/src/legacy-juggler-bridge.ts | 10 +++++++ .../src/loopback-datasource-juggler.ts | 1 + packages/repository/src/repository.ts | 30 +++++++++++++++++-- .../repository-mixin/repository-mixin.test.ts | 14 ++++++++- 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/repository/src/connector.ts b/packages/repository/src/connector.ts index 3eb7bbb9dcb8..c391ab431689 100644 --- a/packages/repository/src/connector.ts +++ b/packages/repository/src/connector.ts @@ -4,6 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {Model} from './model'; +import {AnyObject, Options} from '../index'; /** * Common properties/operations for connectors @@ -15,4 +16,10 @@ export interface Connector { connect(): Promise; // Connect to the underlying system disconnect(): Promise; // Disconnect from the underlying system ping(): Promise; // Ping the underlying system + execute?( + query: string | AnyObject, + // tslint:disable:no-any + parameters: AnyObject | any[], + options?: Options, + ): Promise; } diff --git a/packages/repository/src/legacy-juggler-bridge.ts b/packages/repository/src/legacy-juggler-bridge.ts index 09963a40eb05..38bf7f516e43 100644 --- a/packages/repository/src/legacy-juggler-bridge.ts +++ b/packages/repository/src/legacy-juggler-bridge.ts @@ -14,6 +14,7 @@ import {EntityCrudRepository} from './repository'; export * from './loopback-datasource-juggler'; import {juggler} from './loopback-datasource-juggler'; +import {AnyObject} from '../index'; type DataSourceType = juggler.DataSource; export {DataSourceType}; @@ -195,6 +196,15 @@ export class DefaultCrudRepository return ensurePromise(this.modelClass.exists(id, options)); } + async execute( + query: string | AnyObject, + // tslint:disable:no-any + parameters: AnyObject | any[], + options?: Options, + ): Promise { + throw new Error('Not implemented'); + } + protected toEntity(model: DataObject): T { return new this.entityClass(model.toObject()) as T; } diff --git a/packages/repository/src/loopback-datasource-juggler.ts b/packages/repository/src/loopback-datasource-juggler.ts index 8ce1fef67d64..f14c0a7ecfc2 100644 --- a/packages/repository/src/loopback-datasource-juggler.ts +++ b/packages/repository/src/loopback-datasource-juggler.ts @@ -101,6 +101,7 @@ export declare namespace juggler { * Base model class */ export class ModelBase { + static dataSource?: DataSource; static modelName: string; static definition: ModelDefinition; static attachTo(ds: DataSource): void; diff --git a/packages/repository/src/repository.ts b/packages/repository/src/repository.ts index 916910b34de7..48001e3386fc 100644 --- a/packages/repository/src/repository.ts +++ b/packages/repository/src/repository.ts @@ -4,14 +4,28 @@ // License text available at https://opensource.org/licenses/MIT import {Entity, ValueObject, Model} from './model'; -import {Class, DataObject, Options} from './common-types'; +import {Class, DataObject, Options, AnyObject} from './common-types'; import {DataSource} from './datasource'; import {CrudConnector} from './crud-connector'; import {Filter, Where} from './query'; // tslint:disable:no-unused-variable -export interface Repository {} +export interface Repository { + /** + * Execute a query with the given parameter object or an array of parameters + * @param query The query string or command + * @param parameters The object with name/value pairs or an array of parameter + * values + * @param options Options + */ + execute( + query: string | AnyObject, + // tslint:disable:no-any + parameters: AnyObject | any[], + options?: Options, + ): Promise; +} /** * Basic CRUD operations for ValueObject and Entity. No ID is required. @@ -305,4 +319,16 @@ export class CrudRepositoryImpl return this.count(where, options).then(result => result > 0); } } + + execute( + query: string | AnyObject, + // tslint:disable:no-any + parameters: AnyObject | any[], + options?: Options, + ): Promise { + if (typeof this.connector.execute !== 'function') { + throw new Error('Not implemented'); + } + return this.connector.execute(query, parameters, options); + } } diff --git a/packages/repository/test/unit/repository-mixin/repository-mixin.test.ts b/packages/repository/test/unit/repository-mixin/repository-mixin.test.ts index 74ae34fa6926..35dc20df4b37 100644 --- a/packages/repository/test/unit/repository-mixin/repository-mixin.test.ts +++ b/packages/repository/test/unit/repository-mixin/repository-mixin.test.ts @@ -9,6 +9,9 @@ import { juggler, DataSourceConstructor, Class, + Options, + Repository, + AnyObject, } from '../../../'; import {Application, Component} from '@loopback/core'; @@ -69,7 +72,7 @@ describe('RepositoryMixin', () => { class AppWithRepoMixin extends RepositoryMixin(Application) {} - class NoteRepo { + class NoteRepo implements Repository { model: any; constructor() { @@ -84,6 +87,15 @@ describe('RepositoryMixin', () => { {}, ); } + + execute( + query: string | AnyObject, + // tslint:disable:no-any + parameters: AnyObject | any[], + options?: Options, + ): Promise { + throw Error('Not implemented'); + } } class TestComponent { From 1c585f2438d1165cef264d5065fd36562a07ad55 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 27 Nov 2017 15:17:14 -0800 Subject: [PATCH 3/3] feat: PoC for typeorm repository integration --- packages/repository-typeorm/.gitignore | 3 + packages/repository-typeorm/.npmrc | 1 + packages/repository-typeorm/COMPARISON.md | 61 ++++ packages/repository-typeorm/LICENSE | 25 ++ packages/repository-typeorm/README.md | 16 + packages/repository-typeorm/docs.json | 8 + packages/repository-typeorm/index.d.ts | 6 + packages/repository-typeorm/index.js | 9 + packages/repository-typeorm/index.ts | 7 + packages/repository-typeorm/mysql.sh | 122 ++++++++ packages/repository-typeorm/package.json | 53 ++++ packages/repository-typeorm/src/index.ts | 6 + .../src/repositories/index.ts | 7 + .../src/repositories/typeorm-datasource.ts | 39 +++ .../src/repositories/typeorm-repository.ts | 291 ++++++++++++++++++ .../test/acceptance/typeorm-repository.ts | 81 +++++ packages/repository-typeorm/test/schema.sql | 25 ++ .../repository-typeorm/tsconfig.build.json | 8 + 18 files changed, 768 insertions(+) create mode 100644 packages/repository-typeorm/.gitignore create mode 100644 packages/repository-typeorm/.npmrc create mode 100644 packages/repository-typeorm/COMPARISON.md create mode 100644 packages/repository-typeorm/LICENSE create mode 100644 packages/repository-typeorm/README.md create mode 100644 packages/repository-typeorm/docs.json create mode 100644 packages/repository-typeorm/index.d.ts create mode 100644 packages/repository-typeorm/index.js create mode 100644 packages/repository-typeorm/index.ts create mode 100755 packages/repository-typeorm/mysql.sh create mode 100644 packages/repository-typeorm/package.json create mode 100644 packages/repository-typeorm/src/index.ts create mode 100644 packages/repository-typeorm/src/repositories/index.ts create mode 100644 packages/repository-typeorm/src/repositories/typeorm-datasource.ts create mode 100644 packages/repository-typeorm/src/repositories/typeorm-repository.ts create mode 100644 packages/repository-typeorm/test/acceptance/typeorm-repository.ts create mode 100644 packages/repository-typeorm/test/schema.sql create mode 100644 packages/repository-typeorm/tsconfig.build.json diff --git a/packages/repository-typeorm/.gitignore b/packages/repository-typeorm/.gitignore new file mode 100644 index 000000000000..90a8d96cc3ff --- /dev/null +++ b/packages/repository-typeorm/.gitignore @@ -0,0 +1,3 @@ +*.tgz +dist* +package diff --git a/packages/repository-typeorm/.npmrc b/packages/repository-typeorm/.npmrc new file mode 100644 index 000000000000..43c97e719a5a --- /dev/null +++ b/packages/repository-typeorm/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/repository-typeorm/COMPARISON.md b/packages/repository-typeorm/COMPARISON.md new file mode 100644 index 000000000000..3393f19836b2 --- /dev/null +++ b/packages/repository-typeorm/COMPARISON.md @@ -0,0 +1,61 @@ +# Feature comparison between LoopBack and TypeORM + +## Major features + +| Feature | TypeORM | LoopBack | +| ----------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Databases | MySQL
PostgreSQL
MariaDB
SQLite
MS SQL Server
MongoDB
Oracle
WebSQL | In-memory
MySQL
PostgreSQL
SQLite
MS SQL Server
Oracle
MongoDB
Cloudant
CouchDB
DB2
DB2 Z
DB2 iSeries
DashDB
Informix
Cassandra
Key Value (...)

ElasticSearch
https://github.com/pasindud/awesome-loopback
https://www.npmjs.com/search?q=loopback-connector (189 hits) | +| Model Definition | TypeScript classes with decorators | JSON
TypeScript classes with decorators | +| Data access patterns | ActiveRecord (BaseEntity)
EntityManager
Repository | ActiveRecord (3.x Model)
Repository (4.x) | +| Database Adapters | QueryRunner (monorepo) | Connector (multiple repos/modules) | +| Query | QueryBuilder | Filter JSON Object
FilterBuilder (4.x) | +| Scopes | No | Yes | +| Mutation | QueryBuilder | Where JSON Object | +| Feedback from mutations | No | Yes | +| Mixins | No | Yes | +| Transaction | Yes | Yes | +| Optimistic Locking | Yes | No (requested by AppConnect team) | +| Connection pooling | Yes | Yes | +| Relations | Yes | Yes | +| Relational Join | Yes | No | +| Discovery | No | Yes | +| Migration | Yes | Yes | + +## Side by side claims by TypeORM + +| Claimed by TypeORM | LoopBack | +| ---------------------------------------------------------------- | -------------------------------------------------------------------- | +| supports both DataMapper and ActiveRecord (your choice) | DataMapper is supported by Repository and ActiveRecord by 3.x models | +| entities and columns | Yes | +| database-specific column types | Yes | +| entity manager | Connectors | +| repositories and custom repositories | Yes | +| clean object relational model | Yes | +| associations (relations) | Yes | +| eager and lazy relations | Yes | +| uni-directional, bi-directional and self-referenced relations | Yes | +| supports multiple inheritance patterns | Polymorphic relations | +| cascades | No | +| indices | Yes | +| transactions | Yes | +| migrations and automatic migrations generation | Yes | +| connection pooling | Yes | +| replication | Depending on the driver | +| using multiple database connections | Yes | +| working with multiple databases types | Yes | +| cross-database and cross-schema queries | Yes | +| elegant-syntax, flexible and powerful QueryBuilder | Poc in 4 | +| left and inner joins | No | +| proper pagination for queries using joins | No | +| query caching | No | +| streaming raw results | No | +| logging | Yes - debug | +| listeners and subscribers (hooks) | Yes | +| supports closure table pattern | No | +| schema declaration in models or separate configuration files | Yes | +| connection configuration in json / xml / yml / env formats | Yes | +| works in NodeJS / Browser / Ionic / Cordova / Electron platforms | NodeJS only, 3.x is compatible with Browser | +| TypeScript and JavaScript support | Yes | +| produced code is performant, flexible, clean and maintainable | ? | +| follows all possible best practices | ? | +| CLI | Yes | diff --git a/packages/repository-typeorm/LICENSE b/packages/repository-typeorm/LICENSE new file mode 100644 index 000000000000..f78c63f15825 --- /dev/null +++ b/packages/repository-typeorm/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2017. All Rights Reserved. +Node module: @loopback/repository +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/repository-typeorm/README.md b/packages/repository-typeorm/README.md new file mode 100644 index 000000000000..70fd5770f579 --- /dev/null +++ b/packages/repository-typeorm/README.md @@ -0,0 +1,16 @@ +# @loopback/repository-typeorm + +Repository integration with TypeORM + +## Tests + +run 'npm test' from the root folder. + +## Contributors + +See +[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +## License + +MIT diff --git a/packages/repository-typeorm/docs.json b/packages/repository-typeorm/docs.json new file mode 100644 index 000000000000..726d8b0bb744 --- /dev/null +++ b/packages/repository-typeorm/docs.json @@ -0,0 +1,8 @@ +{ + "content": ["./index.ts", "./src/**/*.ts"], + "codeSectionDepth": 4, + "assets": { + "/": "/docs", + "/docs": "/docs" + } +} diff --git a/packages/repository-typeorm/index.d.ts b/packages/repository-typeorm/index.d.ts new file mode 100644 index 000000000000..1439c16c90db --- /dev/null +++ b/packages/repository-typeorm/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist/src'; diff --git a/packages/repository-typeorm/index.js b/packages/repository-typeorm/index.js new file mode 100644 index 000000000000..2c0a0c33fcd2 --- /dev/null +++ b/packages/repository-typeorm/index.js @@ -0,0 +1,9 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const nodeMajorVersion = +process.versions.node.split('.')[0]; +module.exports = nodeMajorVersion >= 7 ? + require('./dist/src') : + require('./dist6/src'); diff --git a/packages/repository-typeorm/index.ts b/packages/repository-typeorm/index.ts new file mode 100644 index 000000000000..313103704102 --- /dev/null +++ b/packages/repository-typeorm/index.ts @@ -0,0 +1,7 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// NOTE(bajtos) This file is used by VSCode/TypeScriptServer at dev time only +export * from './src'; diff --git a/packages/repository-typeorm/mysql.sh b/packages/repository-typeorm/mysql.sh new file mode 100755 index 000000000000..84d9ad148847 --- /dev/null +++ b/packages/repository-typeorm/mysql.sh @@ -0,0 +1,122 @@ +#!/bin/bash + +### Shell script to spin up a docker container for mysql. + +## color codes +RED='\033[1;31m' +GREEN='\033[1;32m' +YELLOW='\033[1;33m' +CYAN='\033[1;36m' +PLAIN='\033[0m' + +## variables +MYSQL_CONTAINER="mysql_c" +HOST="localhost" +USER="root" +PASSWORD="pass" +PORT=3306 +DATABASE="testdb" +if [ "$1" ]; then + HOST=$1 +fi +if [ "$2" ]; then + PORT=$2 +fi +if [ "$3" ]; then + USER=$3 +fi +if [ "$4" ]; then + PASSWORD=$4 +fi +if [ "$5" ]; then + DATABASE=$5 +fi + +## check if docker exists +printf "\n${RED}>> Checking for docker${PLAIN} ${GREEN}...${PLAIN}" +docker -v > /dev/null 2>&1 +DOCKER_EXISTS=$? +if [ "$DOCKER_EXISTS" -ne 0 ]; then + printf "\n\n${CYAN}Status: ${PLAIN}${RED}Docker not found. Terminating setup.${PLAIN}\n\n" + exit 1 +fi +printf "\n${CYAN}Found docker. Moving on with the setup.${PLAIN}\n" + + +## cleaning up previous builds +printf "\n${RED}>> Finding old builds and cleaning up${PLAIN} ${GREEN}...${PLAIN}" +docker rm -f $MYSQL_CONTAINER > /dev/null 2>&1 +printf "\n${CYAN}Clean up complete.${PLAIN}\n" + +## pull latest mysql image +printf "\n${RED}>> Pulling latest mysql image${PLAIN} ${GREEN}...${PLAIN}" +docker pull mysql:latest > /dev/null 2>&1 +printf "\n${CYAN}Image successfully built.${PLAIN}\n" + +## run the mysql container +printf "\n${RED}>> Starting the mysql container${PLAIN} ${GREEN}...${PLAIN}" +CONTAINER_STATUS=$(docker run --name $MYSQL_CONTAINER -e MYSQL_ROOT_USER=$USER -e MYSQL_ROOT_PASSWORD=$PASSWORD -p $PORT:3306 -d mysql:latest 2>&1) +if [[ "$CONTAINER_STATUS" == *"Error"* ]]; then + printf "\n\n${CYAN}Status: ${PLAIN}${RED}Error starting container. Terminating setup.${PLAIN}\n\n" + exit 1 +fi +docker cp ./test/schema.sql $MYSQL_CONTAINER:/home/ > /dev/null 2>&1 +printf "\n${CYAN}Container is up and running.${PLAIN}\n" + +## export the schema to the mysql database +printf "\n${RED}>> Exporting default schema${PLAIN} ${GREEN}...${PLAIN}\n" + +## command to export schema +docker exec -it $MYSQL_CONTAINER /bin/sh -c "mysql -u$USER -p$PASSWORD < /home/schema.sql" > /dev/null 2>&1 + +## variables needed to health check export schema +OUTPUT=$? +TIMEOUT=120 +TIME_PASSED=0 +WAIT_STRING="." + +printf "\n${GREEN}Waiting for mysql to respond with updated schema $WAIT_STRING${PLAIN}" +while [ "$OUTPUT" -ne 0 ] && [ "$TIMEOUT" -gt 0 ] + do + docker exec -it $MYSQL_CONTAINER /bin/sh -c "mysql -u$USER -p$PASSWORD < /home/schema.sql" > /dev/null 2>&1 + OUTPUT=$? + sleep 1s + TIMEOUT=$((TIMEOUT - 1)) + TIME_PASSED=$((TIME_PASSED + 1)) + + if [ "$TIME_PASSED" -eq 5 ]; then + printf "${GREEN}.${PLAIN}" + TIME_PASSED=0 + fi + done + +if [ "$TIMEOUT" -le 0 ]; then + printf "\n\n${CYAN}Status: ${PLAIN}${RED}Failed to export schema. Terminating setup.${PLAIN}\n\n" + exit 1 +fi +printf "\n${CYAN}Successfully exported schema to database.${PLAIN}\n" + +## create the database +printf "\n${RED}>> Creating the database${PLAIN} ${GREEN}...${PLAIN}" +docker exec -it $MYSQL_CONTAINER /bin/sh -c "mysql -u$USER -p$PASSWORD -e \"DROP DATABASE IF EXISTS $DATABASE\"" > /dev/null 2>&1 +docker exec -it $MYSQL_CONTAINER /bin/sh -c "mysql -u$USER -p$PASSWORD -e \"CREATE DATABASE $DATABASE\"" > /dev/null 2>&1 +DATABASE_CREATED=$? +if [ "$DATABASE_CREATED" -ne 0 ]; then + printf "\n\n${CYAN}Status: ${PLAIN}${RED}Database could not be created. Terminating setup.${PLAIN}\n\n" + exit 1 +fi +printf "\n${CYAN}Successfully created the database.${PLAIN}\n" + +## set env variables for running test +printf "\n${RED}>> Setting env variables to run test${PLAIN} ${GREEN}...${PLAIN}" +export MYSQL_HOST=$HOST +export MYSQL_PORT=$PORT +export MYSQL_USER=$USER +export MYSQL_PASSWORD=$PASSWORD +export MYSQL_DATABASE=$DATABASE +printf "\n${CYAN}Env variables set.${PLAIN}\n" + +printf "\n${CYAN}Status: ${PLAIN}${GREEN}Set up completed successfully.${PLAIN}\n" +printf "\n${CYAN}Instance url: ${YELLOW}mysql://$USER:$PASSWORD@$HOST/$DATABASE${PLAIN}\n" +printf "\n${CYAN}To run the test suite:${PLAIN} ${YELLOW}npm test${PLAIN}\n\n" + diff --git a/packages/repository-typeorm/package.json b/packages/repository-typeorm/package.json new file mode 100644 index 000000000000..fe15d17f3fb1 --- /dev/null +++ b/packages/repository-typeorm/package.json @@ -0,0 +1,53 @@ +{ + "name": "@loopback/repository-typeorm", + "version": "4.0.0-alpha.1", + "description": "Repository based on TypeORM", + "engines": { + "node": ">=6" + }, + "main": "index", + "scripts": { + "acceptance": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/acceptance/**/*.js'", + "build": "npm run build:dist && npm run build:dist6", + "build:current": "lb-tsc", + "build:dist": "lb-tsc es2017", + "build:dist6": "lb-tsc es2015", + "build:apidocs": "lb-apidocs", + "clean": "rm -rf loopback-context*.tgz dist* package", + "prepare": "npm run build && npm run build:apidocs", + "pretest": "npm run build:current", + "test": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/unit/**/*.js' 'DIST/test/acceptance/**/*.js' 'DIST/test/integration/**/*.js'", + "integration": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/integration/**/*.js'", + "unit": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/unit/**/*.js'", + "verify": "npm pack && tar xf loopback-juggler*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "license": "MIT", + "devDependencies": { + "@loopback/build": "^4.0.0-alpha.5", + "@loopback/testlab": "^4.0.0-alpha.14", + "@types/debug": "0.0.30", + "mysql": "^2.15.0" + }, + "dependencies": { + "@loopback/context": "^4.0.0-alpha.20", + "@loopback/core": "^4.0.0-alpha.22", + "@loopback/repository": "^4.0.0-alpha.16", + "@loopback/rest": "^4.0.0-alpha.9", + "debug": "^3.1.0", + "typeorm": "^0.1.6" + }, + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist/src", + "dist6/src", + "api-docs", + "src" + ], + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + } +} diff --git a/packages/repository-typeorm/src/index.ts b/packages/repository-typeorm/src/index.ts new file mode 100644 index 000000000000..ee8aa43dc49f --- /dev/null +++ b/packages/repository-typeorm/src/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository-typeorm +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './repositories'; diff --git a/packages/repository-typeorm/src/repositories/index.ts b/packages/repository-typeorm/src/repositories/index.ts new file mode 100644 index 000000000000..b0310db0f557 --- /dev/null +++ b/packages/repository-typeorm/src/repositories/index.ts @@ -0,0 +1,7 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository-typeorm +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './typeorm-repository'; +export * from './typeorm-datasource'; diff --git a/packages/repository-typeorm/src/repositories/typeorm-datasource.ts b/packages/repository-typeorm/src/repositories/typeorm-datasource.ts new file mode 100644 index 000000000000..c6a8a5871b2f --- /dev/null +++ b/packages/repository-typeorm/src/repositories/typeorm-datasource.ts @@ -0,0 +1,39 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository-typeorm +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + createConnection, + ConnectionOptions, + Connection, + ObjectType, + Repository, + EntityManager, +} from 'typeorm'; + +export class TypeORMDataSource { + connection: Connection; + + constructor(public settings: ConnectionOptions) {} + + async connect(): Promise { + this.connection = await createConnection(this.settings); + return this.connection; + } + + async disconnect(): Promise { + if (!this.connection) return; + await this.connection.close(); + } + + async getEntityManager() { + await this.connect(); + return this.connection.createEntityManager(); + } + + async getRepository(entityClass: ObjectType): Promise> { + await this.connect(); + return this.connection.getRepository(entityClass); + } +} diff --git a/packages/repository-typeorm/src/repositories/typeorm-repository.ts b/packages/repository-typeorm/src/repositories/typeorm-repository.ts new file mode 100644 index 000000000000..3dc1b622a3cc --- /dev/null +++ b/packages/repository-typeorm/src/repositories/typeorm-repository.ts @@ -0,0 +1,291 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository-typeorm +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + EntityCrudRepository, + Entity, + DataObject, + Options, + Filter, + Where, + AnyObject, +} from '@loopback/repository'; +import { + getRepository, + Repository, + SelectQueryBuilder, + QueryBuilder, + UpdateQueryBuilder, + DeleteQueryBuilder, +} from 'typeorm'; +import {DeepPartial} from 'typeorm/common/DeepPartial'; +import {OrderByCondition} from 'typeorm/find-options/OrderByCondition'; + +import {TypeORMDataSource} from './typeorm-datasource'; + +import * as debugModule from 'debug'; +const debug = debugModule('loopback:repository:typeorm'); + +/** + * An implementation of EntityCrudRepository using TypeORM + */ +export class TypeORMRepository + implements EntityCrudRepository { + typeOrmRepo: Repository; + + constructor( + public dataSource: TypeORMDataSource, + public entityClass: typeof Entity & {prototype: T}, + ) {} + + private async init() { + if (this.typeOrmRepo != null) return; + this.typeOrmRepo = >await this.dataSource.getRepository( + this.entityClass, + ); + } + + async save(entity: DataObject, options?: Options): Promise { + await this.init(); + const result = await this.typeOrmRepo.save(>entity); + return result; + } + + async update(entity: DataObject, options?: Options): Promise { + await this.init(); + await this.typeOrmRepo.updateById(entity.getId(), >entity); + return true; + } + + async delete(entity: DataObject, options?: Options): Promise { + await this.init(); + await this.typeOrmRepo.deleteById(entity.getId()); + return true; + } + + async findById(id: ID, filter?: Filter, options?: Options): Promise { + await this.init(); + const result = await this.typeOrmRepo.findOneById(id); + if (result == null) { + throw new Error('Not found'); + } + return result; + } + + async updateById( + id: ID, + data: DataObject, + options?: Options, + ): Promise { + await this.init(); + await this.typeOrmRepo.updateById(data.getId(), >data); + return true; + } + + async replaceById( + id: ID, + data: DataObject, + options?: Options, + ): Promise { + await this.init(); + // FIXME [rfeng]: TypeORM doesn't have a method for `replace` + await this.typeOrmRepo.updateById(data.getId(), >data); + return true; + } + + async deleteById(id: ID, options?: Options): Promise { + await this.init(); + await this.typeOrmRepo.deleteById(id); + return true; + } + + async exists(id: ID, options?: Options): Promise { + await this.init(); + const result = await this.typeOrmRepo.findOneById(id); + return result != null; + } + + async create(dataObject: DataObject, options?: Options): Promise { + await this.init(); + // Please note typeOrmRepo.create() only instantiates model instances. + // It does not persist to the database. + const result = await this.typeOrmRepo.save(>dataObject); + return result; + } + + async createAll( + dataObjects: DataObject[], + options?: Options, + ): Promise { + await this.init(); + const result = await this.typeOrmRepo.save([]>dataObjects); + return result; + } + + async find(filter?: Filter, options?: Options): Promise { + await this.init(); + const queryBuilder = await this.buildQuery(filter); + if (debug.enabled) debug('find: %s', queryBuilder.getSql()); + const result = queryBuilder.getMany(); + return result; + } + + async updateAll( + dataObject: DataObject, + where?: Where, + options?: Options, + ): Promise { + await this.init(); + const queryBuilder = await this.buildUpdate(dataObject, where); + if (debug.enabled) debug('updateAll: %s', queryBuilder.getSql()); + // FIXME [rfeng]: The result is raw data from the DB driver and it varies + // between different DBs + const result = await queryBuilder.execute(); + return result; + } + + async deleteAll(where?: Where, options?: Options): Promise { + await this.init(); + const queryBuilder = await this.buildDelete(where); + if (debug.enabled) debug('deleteAll: %s', queryBuilder.getSql()); + // FIXME [rfeng]: The result is raw data from the DB driver and it varies + // between different DBs + const result = await queryBuilder.execute(); + return result; + } + + async count(where?: Where, options?: Options): Promise { + await this.init(); + const result = await this.typeOrmRepo.count(>where); + return result; + } + + async execute( + query: string | AnyObject, + // tslint:disable:no-any + parameters: AnyObject | any[], + options?: Options, + ): Promise { + await this.init(); + const result = await this.typeOrmRepo.query( + query, + parameters, + ); + return result; + } + + /** + * Convert order clauses to OrderByCondition + * @param order An array of orders + */ + buildOrder(order: string[]) { + let orderBy: OrderByCondition = {}; + for (const o of order) { + const match = /^([^\s]+)( (ASC|DESC))?$/.exec(o); + if (!match) continue; + const field = match[1]; + const dir = (match[3] || 'ASC') as 'ASC' | 'DESC'; + orderBy[match[1]] = dir; + } + return orderBy; + } + + /** + * Build a TypeORM query from LoopBack Filter + * @param filter Filter object + */ + async buildQuery(filter?: Filter): Promise> { + await this.init(); + const queryBuilder = this.typeOrmRepo.createQueryBuilder(); + if (!filter) return queryBuilder; + queryBuilder.limit(filter.limit).offset(filter.offset); + if (filter.fields) { + queryBuilder.select(Object.keys(filter.fields)); + } + if (filter.order) { + queryBuilder.orderBy(this.buildOrder(filter.order)); + } + if (filter.where) { + queryBuilder.where(this.buildWhere(filter.where)); + } + return queryBuilder; + } + + /** + * Convert where object into where clause + * @param where Where object + */ + buildWhere(where: Where): string { + const clauses: string[] = []; + if (where.and) { + const and = where.and.map(w => `(${this.buildWhere(w)})`).join(' AND '); + clauses.push(and); + } + if (where.or) { + const or = where.or.map(w => `(${this.buildWhere(w)})`).join(' OR '); + clauses.push(or); + } + // FIXME [rfeng]: Build parameterized clauses + for (const key in where) { + let clause; + if (key === 'and' || key === 'or') continue; + const condition = where[key]; + if (condition.eq) { + clause = `${key} = ${condition.eq}`; + } else if (condition.neq) { + clause = `${key} != ${condition.neq}`; + } else if (condition.lt) { + clause = `${key} < ${condition.lt}`; + } else if (condition.lte) { + clause = `${key} <= ${condition.lte}`; + } else if (condition.gt) { + clause = `${key} > ${condition.gt}`; + } else if (condition.gte) { + clause = `${key} >= ${condition.gte}`; + } else if (condition.inq) { + const vals = condition.inq.join(', '); + clause = `${key} IN (${vals})`; + } else if (condition.between) { + const v1 = condition.between[0]; + const v2 = condition.between[1]; + clause = `${key} BETWEEN ${v1} AND ${v2}`; + } else { + // Shorthand form: {x:1} => X = 1 + clause = `${key} = ${condition}`; + } + clauses.push(clause); + } + return clauses.join(' AND '); + } + + /** + * Build an `update` statement from LoopBack-style parameters + * @param dataObject Data object to be updated + * @param where Where object + */ + async buildUpdate(dataObject: DataObject, where?: Where) { + await this.init(); + let queryBuilder = this.typeOrmRepo + .createQueryBuilder() + .update(this.entityClass) + .set(dataObject); + if (where) queryBuilder.where(this.buildWhere(where)); + return queryBuilder; + } + + /** + * Build a `delete` statement from LoopBack-style parameters + * @param where Where object + */ + async buildDelete(where?: Where) { + await this.init(); + let queryBuilder = this.typeOrmRepo + .createQueryBuilder() + .delete() + .from(this.entityClass); + if (where) queryBuilder.where(this.buildWhere(where)); + return queryBuilder; + } +} diff --git a/packages/repository-typeorm/test/acceptance/typeorm-repository.ts b/packages/repository-typeorm/test/acceptance/typeorm-repository.ts new file mode 100644 index 000000000000..570d3bef408f --- /dev/null +++ b/packages/repository-typeorm/test/acceptance/typeorm-repository.ts @@ -0,0 +1,81 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/repository-typeorm +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {TypeORMDataSource, TypeORMRepository} from '../..'; +import {MysqlConnectionOptions} from 'typeorm/driver/mysql/MysqlConnectionOptions'; + +import {Entity as Base} from '@loopback/repository'; +import {Entity, Column, PrimaryGeneratedColumn, Repository} from 'typeorm'; + +describe('TypeORM Repository', () => { + @Entity('NOTE') + class Note extends Base { + @PrimaryGeneratedColumn() id: number; + + @Column({ + length: 100, + }) + title: string; + + @Column('text') content: string; + } + + const options: MysqlConnectionOptions = { + type: 'mysql', + host: 'localhost', + port: 3306, + username: 'root', + password: 'pass', + database: 'TESTDB', + entities: [Note], + }; + + let repository: TypeORMRepository; + + before(() => { + const ds = new TypeORMDataSource(options); + repository = new TypeORMRepository(ds, Note); + }); + + it('creates new instances', async () => { + let result = await repository.create({ + title: 'Note1', + content: 'This is note #1', + }); + console.log(result); + result = await repository.create({ + title: 'Note2', + content: 'This is note #2', + }); + console.log(result); + }); + + it('finds matching instances', async () => { + const result = await repository.find(); + console.log(result); + }); + + it('finds matching instances with filter', async () => { + const result = await repository.find({ + limit: 2, + order: ['title DESC'], + where: {id: {lt: 5}}, + }); + console.log(result); + }); + + it('updates matching instances', async () => { + const result = await repository.updateAll( + {content: 'This is note #2 - edited'}, + {id: 2}, + ); + console.log(result); + }); + + it('deletes all instances', async () => { + const result = await repository.deleteAll({}); + console.log(result); + }); +}); diff --git a/packages/repository-typeorm/test/schema.sql b/packages/repository-typeorm/test/schema.sql new file mode 100644 index 000000000000..f85fd5ae3233 --- /dev/null +++ b/packages/repository-typeorm/test/schema.sql @@ -0,0 +1,25 @@ +-- +-- Current Database: `TESTDB` +-- + +/*!40000 DROP DATABASE IF EXISTS `TESTDB`*/; + +CREATE DATABASE /*!32312 IF NOT EXISTS*/ `TESTDB` /*!40100 DEFAULT CHARACTER SET utf8 */; + +USE `TESTDB`; + +-- +-- Table structure for table `NOTE` +-- + +DROP TABLE IF EXISTS `NOTE`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `NOTE` ( + `ID` int(11) NOT NULL AUTO_INCREMENT, + `TITLE` varchar(64) DEFAULT NULL, + `CONTENT` text DEFAULT NULL, + PRIMARY KEY (`ID`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8; + + diff --git a/packages/repository-typeorm/tsconfig.build.json b/packages/repository-typeorm/tsconfig.build.json new file mode 100644 index 000000000000..855e02848b35 --- /dev/null +++ b/packages/repository-typeorm/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../build/config/tsconfig.common.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["src", "test"] +}