Skip to content

Commit 52f44c5

Browse files
authored
feat: implement filter operators in restful service (#411)
1 parent 4ebaa1f commit 52f44c5

File tree

2 files changed

+182
-14
lines changed

2 files changed

+182
-14
lines changed

packages/server/src/api/rest/index.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,24 @@ class InvalidValueError extends Error {
9393

9494
const DEFAULT_PAGE_SIZE = 100;
9595

96+
const FilterOperations = [
97+
'lt',
98+
'lte',
99+
'gt',
100+
'gte',
101+
'contains',
102+
'icontains',
103+
'search',
104+
'startsWith',
105+
'endsWith',
106+
'has',
107+
'hasEvery',
108+
'hasSome',
109+
'isEmpty',
110+
] as const;
111+
112+
type FilterOperationType = (typeof FilterOperations)[number] | undefined;
113+
96114
/**
97115
* RESTful style API request handler (compliant with JSON:API)
98116
*/
@@ -1227,6 +1245,7 @@ class RequestHandler {
12271245
if (!match || !match.groups) {
12281246
continue;
12291247
}
1248+
12301249
const filterKeys = match.groups.match
12311250
.replaceAll(/[[\]]/g, ' ')
12321251
.split(' ')
@@ -1243,7 +1262,21 @@ class RequestHandler {
12431262

12441263
for (const filterValue of enumerate(value)) {
12451264
for (let i = 0; i < filterKeys.length; i++) {
1246-
const filterKey = filterKeys[i];
1265+
// extract filter operation from (optional) trailing $op
1266+
let filterKey = filterKeys[i];
1267+
let filterOp: FilterOperationType | undefined;
1268+
const pos = filterKey.indexOf('$');
1269+
if (pos > 0) {
1270+
filterOp = filterKey.substring(pos + 1) as FilterOperationType;
1271+
filterKey = filterKey.substring(0, pos);
1272+
}
1273+
1274+
if (!!filterOp && !FilterOperations.includes(filterOp)) {
1275+
return {
1276+
filter: undefined,
1277+
error: this.makeError('invalidFilter', `invalid filter operation: ${filterOp}`),
1278+
};
1279+
}
12471280

12481281
const fieldInfo =
12491282
filterKey === 'id'
@@ -1259,11 +1292,11 @@ class RequestHandler {
12591292
// must be the last segment of a filter
12601293
return { filter: undefined, error: this.makeError('invalidFilter') };
12611294
}
1262-
curr[fieldInfo.name] = this.makeFilterValue(fieldInfo, filterValue);
1295+
curr[fieldInfo.name] = this.makeFilterValue(fieldInfo, filterValue, filterOp);
12631296
} else {
12641297
// relation field
12651298
if (i === filterKeys.length - 1) {
1266-
curr[fieldInfo.name] = this.makeFilterValue(fieldInfo, filterValue);
1299+
curr[fieldInfo.name] = this.makeFilterValue(fieldInfo, filterValue, filterOp);
12671300
} else {
12681301
// keep going
12691302
curr = curr[fieldInfo.name] = {};
@@ -1399,18 +1432,42 @@ class RequestHandler {
13991432
return { select: result, error: undefined, allIncludes };
14001433
}
14011434

1402-
private makeFilterValue(fieldInfo: FieldInfo, value: string): any {
1435+
private makeFilterValue(fieldInfo: FieldInfo, value: string, op: FilterOperationType): any {
14031436
if (fieldInfo.isDataModel) {
14041437
// relation filter is converted to an ID filter
14051438
const info = this.typeMap[lowerCaseFirst(fieldInfo.type)];
14061439
if (fieldInfo.isArray) {
14071440
// filtering a to-many relation, imply 'some' operator
1408-
return { some: this.makeIdFilter(info.idField, info.idFieldType, value) };
1441+
const values = value.split(',').filter((i) => i);
1442+
const filterValue =
1443+
values.length > 1
1444+
? { OR: values.map((v) => this.makeIdFilter(info.idField, info.idFieldType, v)) }
1445+
: this.makeIdFilter(info.idField, info.idFieldType, value);
1446+
return { some: filterValue };
14091447
} else {
14101448
return { is: this.makeIdFilter(info.idField, info.idFieldType, value) };
14111449
}
14121450
} else {
1413-
return this.coerce(fieldInfo.type, value);
1451+
const coerced = this.coerce(fieldInfo.type, value);
1452+
switch (op) {
1453+
case 'icontains':
1454+
return { contains: coerced, mode: 'insensitive' };
1455+
case 'hasSome':
1456+
case 'hasEvery': {
1457+
const values = value
1458+
.split(',')
1459+
.filter((i) => i)
1460+
.map((v) => this.coerce(fieldInfo.type, v));
1461+
return { [op]: values };
1462+
}
1463+
case 'isEmpty':
1464+
if (value !== 'true' && value !== 'false') {
1465+
throw new InvalidValueError(`Not a boolean: ${value}`);
1466+
}
1467+
return { isEmpty: value === 'true' ? true : false };
1468+
default:
1469+
return op ? { [op]: coerced } : { equals: coerced };
1470+
}
14141471
}
14151472
}
14161473

packages/server/tests/api/rest/rest.test.ts

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ describe('REST server tests', () => {
319319
expect((r.body as any).data).toHaveLength(1);
320320
expect((r.body as any).data[0]).toMatchObject({ id: 'user2' });
321321

322-
// attribute filter
322+
// String filter
323323
r = await handler({
324324
method: 'get',
325325
path: '/user',
@@ -329,30 +329,53 @@ describe('REST server tests', () => {
329329
expect((r.body as any).data).toHaveLength(1);
330330
expect((r.body as any).data[0]).toMatchObject({ id: 'user1' });
331331

332-
// filter to empty
333332
r = await handler({
334333
method: 'get',
335334
path: '/user',
336-
query: { ['filter[id]']: 'user3' },
335+
query: { ['filter[email$contains]']: '1@abc' },
336+
prisma,
337+
});
338+
expect((r.body as any).data).toHaveLength(1);
339+
expect((r.body as any).data[0]).toMatchObject({ id: 'user1' });
340+
341+
r = await handler({
342+
method: 'get',
343+
path: '/user',
344+
query: { ['filter[email$contains]']: '1@bc' },
337345
prisma,
338346
});
339347
expect((r.body as any).data).toHaveLength(0);
340348

341-
// to-many relation collection filter
342349
r = await handler({
343350
method: 'get',
344351
path: '/user',
345-
query: { ['filter[posts]']: '2' },
352+
query: { ['filter[email$startsWith]']: 'user1' },
346353
prisma,
347354
});
348355
expect((r.body as any).data).toHaveLength(1);
349-
expect((r.body as any).data[0]).toMatchObject({ id: 'user2' });
356+
expect((r.body as any).data[0]).toMatchObject({ id: 'user1' });
350357

351-
// multi filter
352358
r = await handler({
353359
method: 'get',
354360
path: '/user',
355-
query: { ['filter[id]']: 'user1', ['filter[posts]']: '2' },
361+
query: { ['filter[email$startsWith]']: 'ser1' },
362+
prisma,
363+
});
364+
expect((r.body as any).data).toHaveLength(0);
365+
366+
r = await handler({
367+
method: 'get',
368+
path: '/user',
369+
query: { ['filter[email$endsWith]']: '1@abc.com' },
370+
prisma,
371+
});
372+
expect((r.body as any).data).toHaveLength(1);
373+
expect((r.body as any).data[0]).toMatchObject({ id: 'user1' });
374+
375+
r = await handler({
376+
method: 'get',
377+
path: '/user',
378+
query: { ['filter[email$endsWith]']: '1@abc' },
356379
prisma,
357380
});
358381
expect((r.body as any).data).toHaveLength(0);
@@ -367,6 +390,41 @@ describe('REST server tests', () => {
367390
expect((r.body as any).data).toHaveLength(1);
368391
expect((r.body as any).data[0]).toMatchObject({ id: 2 });
369392

393+
r = await handler({
394+
method: 'get',
395+
path: '/post',
396+
query: { ['filter[viewCount$gt]']: '0' },
397+
prisma,
398+
});
399+
expect((r.body as any).data).toHaveLength(1);
400+
expect((r.body as any).data[0]).toMatchObject({ id: 2 });
401+
402+
r = await handler({
403+
method: 'get',
404+
path: '/post',
405+
query: { ['filter[viewCount$gte]']: '1' },
406+
prisma,
407+
});
408+
expect((r.body as any).data).toHaveLength(1);
409+
expect((r.body as any).data[0]).toMatchObject({ id: 2 });
410+
411+
r = await handler({
412+
method: 'get',
413+
path: '/post',
414+
query: { ['filter[viewCount$lt]']: '0' },
415+
prisma,
416+
});
417+
expect((r.body as any).data).toHaveLength(0);
418+
419+
r = await handler({
420+
method: 'get',
421+
path: '/post',
422+
query: { ['filter[viewCount$lte]']: '0' },
423+
prisma,
424+
});
425+
expect((r.body as any).data).toHaveLength(1);
426+
expect((r.body as any).data[0]).toMatchObject({ id: 1 });
427+
370428
// Boolean filter
371429
r = await handler({
372430
method: 'get',
@@ -377,6 +435,42 @@ describe('REST server tests', () => {
377435
expect((r.body as any).data).toHaveLength(1);
378436
expect((r.body as any).data[0]).toMatchObject({ id: 2 });
379437

438+
// filter to empty
439+
r = await handler({
440+
method: 'get',
441+
path: '/user',
442+
query: { ['filter[id]']: 'user3' },
443+
prisma,
444+
});
445+
expect((r.body as any).data).toHaveLength(0);
446+
447+
// to-many relation collection filter
448+
r = await handler({
449+
method: 'get',
450+
path: '/user',
451+
query: { ['filter[posts]']: '2' },
452+
prisma,
453+
});
454+
expect((r.body as any).data).toHaveLength(1);
455+
expect((r.body as any).data[0]).toMatchObject({ id: 'user2' });
456+
457+
r = await handler({
458+
method: 'get',
459+
path: '/user',
460+
query: { ['filter[posts]']: '1,2,3' },
461+
prisma,
462+
});
463+
expect((r.body as any).data).toHaveLength(2);
464+
465+
// multi filter
466+
r = await handler({
467+
method: 'get',
468+
path: '/user',
469+
query: { ['filter[id]']: 'user1', ['filter[posts]']: '2' },
470+
prisma,
471+
});
472+
expect((r.body as any).data).toHaveLength(0);
473+
380474
// to-one relation filter
381475
r = await handler({
382476
method: 'get',
@@ -420,6 +514,23 @@ describe('REST server tests', () => {
420514
},
421515
],
422516
});
517+
518+
// invalid filter operation
519+
r = await handler({
520+
method: 'get',
521+
path: '/user',
522+
query: { ['filter[email$foo]']: '1' },
523+
prisma,
524+
});
525+
expect(r.body).toMatchObject({
526+
errors: [
527+
{
528+
status: 400,
529+
code: 'invalid-filter',
530+
title: 'Invalid filter',
531+
},
532+
],
533+
});
423534
});
424535

425536
it('related data filtering', async () => {

0 commit comments

Comments
 (0)