-
Notifications
You must be signed in to change notification settings - Fork 38
/
BaseService.ts
464 lines (404 loc) · 14.9 KB
/
BaseService.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
import { validate } from 'class-validator';
import { ArgumentValidationError } from 'type-graphql';
import {
Brackets,
DeepPartial,
EntityManager,
getRepository,
Repository,
SelectQueryBuilder
} from 'typeorm';
import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
import { isArray } from 'util';
import { debug } from '../decorators';
import { StandardDeleteResponse } from '../tgql';
import { addQueryBuilderWhereItem } from '../torm';
import { BaseModel } from './';
import { ConnectionInputFields, GraphQLInfoService } from './GraphQLInfoService';
import {
ConnectionResult,
RelayFirstAfter,
RelayLastBefore,
RelayPageOptions,
RelayService
} from './RelayService';
import { StringMap, WhereInput } from './types';
export interface BaseOptions {
manager?: EntityManager; // Allows consumers to pass in a TransactionManager
}
interface WhereFilterAttributes {
[key: string]: string | number | null;
}
type WhereExpression = {
AND?: WhereExpression[];
OR?: WhereExpression[];
} & WhereFilterAttributes;
export type LimitOffset = {
limit: number;
offset?: number;
};
export type PaginationOptions = LimitOffset | RelayPageOptions;
export type RelayPageOptionsInput = {
first?: number;
after?: string;
last?: number;
before?: string;
};
function isLastBefore(
pageType: PaginationOptions | RelayPageOptionsInput
): pageType is RelayLastBefore {
return (pageType as RelayLastBefore).last !== undefined;
}
export class BaseService<E extends BaseModel> {
manager: EntityManager;
columnMap: StringMap;
klass: string;
relayService: RelayService;
graphQLInfoService: GraphQLInfoService;
// TODO: any -> ObjectType<E> (or something close)
// V3: Only ask for entityClass, we can get repository and manager from that
constructor(protected entityClass: any, protected repository: Repository<E>) {
if (!entityClass) {
throw new Error('BaseService requires an entity Class');
}
// TODO: use DI
this.relayService = new RelayService();
this.graphQLInfoService = new GraphQLInfoService();
// V3: remove the need to inject a repository, we simply need the entityClass and then we can do
// everything we need to do.
// For now, we'll keep the API the same so that there are no breaking changes
this.manager = this.repository.manager;
// TODO: This handles an issue with typeorm-typedi-extensions where it is unable to
// Inject the proper repository
if (!repository) {
this.repository = getRepository(entityClass);
}
if (!repository) {
throw new Error(`BaseService requires a valid repository, class ${entityClass}`);
}
// Need a mapping of camelCase field name to the modified case using the naming strategy. For the standard
// SnakeNamingStrategy this would be something like { id: 'id', stringField: 'string_field' }
this.columnMap = this.repository.metadata.columns.reduce(
(prev: StringMap, column: ColumnMetadata) => {
prev[column.propertyPath] = column.databasePath;
return prev;
},
{}
);
this.klass = this.repository.metadata.name.toLowerCase();
}
async find<W extends WhereInput>(
where?: any, // V3: WhereExpression = {},
orderBy?: string,
limit?: number,
offset?: number,
fields?: string[]
): Promise<E[]> {
// TODO: FEATURE - make the default limit configurable
limit = limit ?? 20;
return this.buildFindQuery<W>(where, orderBy, { limit, offset }, fields).getMany();
}
@debug('base-service:findConnection')
async findConnection<W extends WhereInput>(
whereUserInput: any = {}, // V3: WhereExpression = {},
orderBy?: string | string[],
_pageOptions: RelayPageOptionsInput = {},
fields?: ConnectionInputFields
): Promise<ConnectionResult<E>> {
// TODO: if the orderby items aren't included in `fields`, should we automatically include?
// TODO: FEATURE - make the default limit configurable
const DEFAULT_LIMIT = 50;
const { first, after, last, before } = _pageOptions;
let relayPageOptions;
let limit;
let cursor;
if (isLastBefore(_pageOptions)) {
limit = last || DEFAULT_LIMIT;
cursor = before;
relayPageOptions = {
last: limit,
before
} as RelayLastBefore;
} else {
limit = first || DEFAULT_LIMIT;
cursor = after;
relayPageOptions = {
first: limit,
after
} as RelayFirstAfter;
}
const requestedFields = this.graphQLInfoService.connectionOptions(fields);
const sorts = this.relayService.normalizeSort(orderBy);
let whereFromCursor = {};
if (cursor) {
whereFromCursor = this.relayService.getFilters(orderBy, relayPageOptions);
}
const whereCombined: any = { AND: [whereUserInput, whereFromCursor] };
const qb = this.buildFindQuery<W>(
whereCombined,
this.relayService.effectiveOrderStrings(sorts, relayPageOptions),
{ limit: limit + 1 }, // We ask for 1 too many so that we know if there is an additional page
requestedFields.selectFields
);
let rawData;
let totalCountOption = {};
if (requestedFields.totalCount) {
let totalCount;
[rawData, totalCount] = await qb.getManyAndCount();
totalCountOption = { totalCount };
} else {
rawData = await qb.getMany();
}
// If we got the n+1 that we requested, pluck the last item off
const returnData = rawData.length > limit ? rawData.slice(0, limit) : rawData;
return {
...totalCountOption,
edges: returnData.map((item: E) => {
return {
node: item,
cursor: this.relayService.encodeCursor(item, sorts)
};
}),
pageInfo: this.relayService.getPageInfo(rawData, sorts, relayPageOptions)
};
}
@debug('base-service:buildFindQuery')
buildFindQuery<W extends WhereInput>(
where: WhereExpression = {},
orderBy?: string | string[],
pageOptions?: LimitOffset,
fields?: string[]
): SelectQueryBuilder<E> {
const DEFAULT_LIMIT = 50;
let qb = this.manager.createQueryBuilder<E>(this.entityClass, this.klass);
if (!pageOptions) {
pageOptions = {
limit: DEFAULT_LIMIT
};
}
qb = qb.take(pageOptions.limit || DEFAULT_LIMIT);
if (pageOptions.offset) {
qb = qb.skip(pageOptions.offset);
}
if (fields) {
// We always need to select ID or dataloaders will not function properly
if (fields.indexOf('id') === -1) {
fields.push('id');
}
// Querybuilder requires you to prefix all fields with the table alias. It also requires you to
// specify the field name using it's TypeORM attribute name, not the camel-cased DB column name
const selection = fields
.filter(field => this.columnMap[field]) // This will filter out any association records that come in @Fields
.map(field => `${this.klass}.${field}`);
qb = qb.select(selection);
}
if (orderBy) {
if (!isArray(orderBy)) {
orderBy = [orderBy];
}
orderBy.forEach((orderByItem: string) => {
const parts = orderByItem.toString().split('_');
// TODO: ensure attr is one of the properties on the model
const attr = parts[0];
const direction: 'ASC' | 'DESC' = parts[1] as 'ASC' | 'DESC';
qb = qb.addOrderBy(this.attrToDBColumn(attr), direction);
});
}
// Soft-deletes are filtered out by default, setting `deletedAt_all` is the only way to turn this off
const hasDeletedAts = Object.keys(where).find(key => key.indexOf('deletedAt_') === 0);
// If no deletedAt filters specified, hide them by default
if (!hasDeletedAts) {
// eslint-disable-next-line @typescript-eslint/camelcase
where.deletedAt_eq = null; // Filter out soft-deleted items
} else if (typeof where.deletedAt_all !== 'undefined') {
// Delete this param so that it doesn't try to filter on the magic `all` param
// Put this here so that we delete it even if `deletedAt_all: false` specified
delete where.deletedAt_all;
} else {
// If we get here, the user has added a different deletedAt filter, like deletedAt_gt: <date>
// do nothing because the specific deleted at filters will be added by processWhereOptions
}
// Keep track of a counter so that TypeORM doesn't reuse our variables that get passed into the query if they
// happen to reference the same column
const paramKeyCounter = { counter: 0 };
const processWheres = (
qb: SelectQueryBuilder<E>,
where: WhereFilterAttributes
): SelectQueryBuilder<E> => {
// where is of shape { userName_contains: 'a' }
Object.keys(where).forEach((k: string) => {
const paramKey = `param${paramKeyCounter.counter}`;
// increment counter each time we add a new where clause so that TypeORM doesn't reuse our input variables
paramKeyCounter.counter = paramKeyCounter.counter + 1;
const key = k as keyof W; // userName_contains
const parts = key.toString().split('_'); // ['userName', 'contains']
const attr = parts[0]; // userName
const operator = parts.length > 1 ? parts[1] : 'eq'; // contains
return addQueryBuilderWhereItem(
qb,
paramKey,
this.attrToDBColumn(attr),
operator,
where[key]
);
});
return qb;
};
// WhereExpression comes in the following shape:
// {
// AND?: WhereInput[];
// OR?: WhereInput[];
// [key: string]: string | number | null;
// }
const processWhereInput = (
qb: SelectQueryBuilder<E>,
where: WhereExpression
): SelectQueryBuilder<E> => {
const { AND, OR, ...rest } = where;
if (AND && AND.length) {
const ands = AND.filter(value => JSON.stringify(value) !== '{}');
if (ands.length) {
qb.andWhere(
new Brackets(qb2 => {
ands.forEach((where: WhereExpression) => {
if (Object.keys(where).length === 0) {
return; // disregard empty where objects
}
qb2.andWhere(
new Brackets(qb3 => {
processWhereInput(qb3 as SelectQueryBuilder<any>, where);
return qb3;
})
);
});
})
);
}
}
if (OR && OR.length) {
const ors = OR.filter(value => JSON.stringify(value) !== '{}');
if (ors.length) {
qb.andWhere(
new Brackets(qb2 => {
ors.forEach((where: WhereExpression) => {
if (Object.keys(where).length === 0) {
return; // disregard empty where objects
}
qb2.orWhere(
new Brackets(qb3 => {
processWhereInput(qb3 as SelectQueryBuilder<any>, where);
return qb3;
})
);
});
})
);
}
}
if (rest) {
processWheres(qb, rest);
}
return qb;
};
if (Object.keys(where).length) {
processWhereInput(qb, where);
}
return qb;
}
async findOne<W>(
where: W // V3: WhereExpression
): Promise<E> {
const items = await this.find(where);
if (!items.length) {
throw new Error(`Unable to find ${this.entityClass.name} where ${JSON.stringify(where)}`);
} else if (items.length > 1) {
throw new Error(
`Found ${items.length} ${this.entityClass.name}s where ${JSON.stringify(where)}`
);
}
return items[0];
}
async create(data: DeepPartial<E>, userId: string, options?: BaseOptions): Promise<E> {
const manager = options?.manager ?? this.manager;
const entity = manager.create<E>(this.entityClass, { ...data, createdById: userId });
// Validate against the the data model
// Without `skipMissingProperties`, some of the class-validator validations (like MinLength)
// will fail if you don't specify the property
const errors = await validate(entity, { skipMissingProperties: true });
if (errors.length) {
// TODO: create our own error format
throw new ArgumentValidationError(errors);
}
// TODO: remove any when this is fixed: https://github.com/Microsoft/TypeScript/issues/21592
// TODO: Fix `any`
return manager.save(entity as any, { reload: true });
}
async createMany(data: DeepPartial<E>[], userId: string, options?: BaseOptions): Promise<E[]> {
const manager = options?.manager ?? this.manager;
data = data.map(item => {
return { ...item, createdById: userId };
});
const results = manager.create(this.entityClass, data);
// Validate against the the data model
// Without `skipMissingProperties`, some of the class-validator validations (like MinLength)
// will fail if you don't specify the property
for (const obj of results) {
const errors = await validate(obj, { skipMissingProperties: true });
if (errors.length) {
// TODO: create our own error format that matches Mike B's format
throw new ArgumentValidationError(errors);
}
}
return manager.save(results, { reload: true });
}
// TODO: There must be a more succinct way to:
// - Test the item exists
// - Update
// - Return the full object
// NOTE: assumes all models have a unique `id` field
// W extends Partial<E>
async update<W extends any>(
data: DeepPartial<E>,
where: W, // V3: WhereExpression,
userId: string,
options?: BaseOptions
): Promise<E> {
const manager = options?.manager ?? this.manager;
const found = await this.findOne(where);
const mergeData = ({ id: found.id, updatedById: userId } as any) as DeepPartial<E>;
const entity = manager.merge<E>(this.entityClass, new this.entityClass(), data, mergeData);
// skipMissingProperties -> partial validation of only supplied props
const errors = await validate(entity, { skipMissingProperties: true });
if (errors.length) {
throw new ArgumentValidationError(errors);
}
const result = await manager.save<E>(entity);
return manager.findOneOrFail(this.entityClass, result.id);
}
async delete<W extends object>(
where: W,
userId: string,
options?: BaseOptions
): Promise<StandardDeleteResponse> {
const manager = options?.manager ?? this.manager;
const data = {
deletedAt: new Date().toISOString(),
deletedById: userId
};
const whereNotDeleted = {
...where,
deletedAt: null
};
const found = await manager.findOneOrFail<E>(this.entityClass, whereNotDeleted as any);
const idData = ({ id: found.id } as any) as DeepPartial<E>;
const entity = manager.merge<E>(this.entityClass, new this.entityClass(), data as any, idData);
await manager.save(entity as any);
return { id: found.id };
}
attrsToDBColumns = (attrs: string[]): string[] => {
return attrs.map(this.attrToDBColumn);
};
attrToDBColumn = (attr: string): string => {
return `"${this.klass}"."${this.columnMap[attr]}"`;
};
}