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

Add HAS operator #6

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,5 @@ A list of all operators are as follows:
| `lte` | less than or equal to |
| `startswith` | starts with |
| `endswith` | ends with |
| `has` | array has any element |

Choose a reason for hiding this comment

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

Nice!


46 changes: 34 additions & 12 deletions lib/sort-and-filter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
createParamDecorator, ExecutionContext
} from '@nestjs/common';
import { Equal, FindManyOptions, FindOperator, ILike, LessThan, LessThanOrEqual, Like, MoreThan, MoreThanOrEqual, Not, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Brackets, Equal, FindManyOptions, FindOperator, ILike, In, LessThan, LessThanOrEqual, Like, MoreThan, MoreThanOrEqual, Not, ObjectLiteral, SelectQueryBuilder } from 'typeorm';

export enum SearchOps {
EQUALS = 'eq',
Expand All @@ -13,7 +13,8 @@ export enum SearchOps {
GTE = 'gte',
LTE = 'lte',
STARTSWITH = 'startswith',
ENDSWITH = 'endswith'
ENDSWITH = 'endswith',
HAS = 'has',
}

export interface SortAndFilterParams {
Expand Down Expand Up @@ -107,6 +108,7 @@ const searchOpToOperator = {
[SearchOps.LT]: '<',
[SearchOps.GTE]: '>=',
[SearchOps.LTE]: '<=',
[SearchOps.HAS]: '@>',
};

function NotEqual<T>(value: T | FindOperator<T>): FindOperator<T> {
Expand All @@ -125,17 +127,18 @@ const searchOpToTypeormOperator = {
[SearchOps.LT]: LessThan,
[SearchOps.GTE]: MoreThanOrEqual,
[SearchOps.LTE]: LessThanOrEqual,
[SearchOps.HAS]: In,
};

function paramTransform(param: string, op: SearchOps) {
function paramTransform(param: string, op: SearchOps): string {
switch(op) {
case SearchOps.CONTAINS:
case SearchOps.ICONTAINS:
return `%${param}%`
case SearchOps.STARTSWITH:
return `${param}%`
case SearchOps.ENDSWITH:
return `%${param}`
return `%${param}`;
}

return param;
Expand Down Expand Up @@ -166,14 +169,25 @@ SelectQueryBuilder.prototype.sortAndFilter = function<Entity>(this: SelectQueryB
let counter = 0;
for (let [key, value] of Object.entries(params.filter)) {
let op = searchOpToOperator[value.op] || '=';
let param = paramTransform(value.value, value.op);


if (key.includes('.')) {
key = buildSortAndFilterJoins(this, key, 'Filter');
}

this.andWhere(`${this.alias}.${key} ${op} :filterValue${counter}`, { [`filterValue${counter}`]: param });
++counter;
this.andWhere(new Brackets(qb => {
value.value.split('|').forEach(p => {
let filterValue: string | string[];
if (value.op === SearchOps.HAS)
filterValue = [ p ];
else
filterValue = paramTransform(p, value.op);

qb.orWhere(`${this.alias}.${key} ${op} :filterValue${counter}`, {
[`filterValue${counter}`]: filterValue
});
++counter;
});
}));
}
}

Expand All @@ -191,15 +205,23 @@ export const sortAndFilter = <T extends ObjectLiteral>(params: SortAndFilterPara
}

if (params.filter) {
options.where = {};
for (let [key, value] of Object.entries(params.filter)) {
options.where ||= {};

let param = paramTransform(value.value, value.op);
const op = searchOpToTypeormOperator[value.op] || Equal;

options.where[key] = op(param);
if (value.value.includes('|')) {
// NOTE(justin): OR group
throw new Error('Repository does not support grouped OR\'s you must use the QueryBuilder.');
} else {
if (value.op === SearchOps.HAS) {
options.where[key] = [value.value];
} else {
options.where[key] = op(paramTransform(value.value, value.op));
}
}
}
}

return options;
}
}
56 changes: 55 additions & 1 deletion lib/spec/all.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,49 @@ import { expect } from "chai";
import supertest = require("supertest");
import { TestFactory } from "./factories";
import { app } from "./helper";
import { SomeEnum } from "./test-app/test.entity";

let testFactory = new TestFactory();


context('QueryBuilder only', () => {
describe('Filtering with OR', () => {
it('can do multiple values, which are ORed', async () => {
const record = await testFactory.create({ name: 'find me!', email: '[email protected]' });
await testFactory.create({ name: 'me too!', email: '[email protected]' });
const res = await supertest(app.getHttpServer())
.get('/tests?filter=name__contains:find|too')

const { body } = res;

expect(body.results.length).to.eq(2);
});


it('can filter arrays by multiple values', async () => {
const record = await testFactory.create({
name: 'name',
email: '[email protected]',
});
record.someArray = [SomeEnum.Choice1, SomeEnum.Choice3];
const record2 = await testFactory.create({
name: 'another',
email: '[email protected]',
});
record2.someArray = [SomeEnum.Choice3];
await record.save();
await record2.save();


const url = `/tests?filter=someArray__has:2|1`;
const res = await supertest(app.getHttpServer()).get(url);
const { body } = res;

expect(body.results.length).to.eq(1);
});
});
});

// NOTE(justin): runs all tests for both repo and query builder controllers
['/tests', '/tests/repo'].forEach(api => {
context(api, () => {
Expand Down Expand Up @@ -82,6 +121,21 @@ let testFactory = new TestFactory();
expect(body.results[0].id).to.eq(record.id);
});

it('can filter arrays', async () => {
const record = await testFactory.create({
name: 'name',
email: '[email protected]',
});
record.someArray = [SomeEnum.Choice1, SomeEnum.Choice3];
await record.save();

const url = `${api}?filter=someArray__has:2`;
const res = await supertest(app.getHttpServer()).get(url);
const { body } = res;

expect(body.results.length).to.eq(0);
});

it('can "not equals" filter', async () => {
await testFactory.create({ name: 'thing' });

Expand All @@ -103,7 +157,7 @@ let testFactory = new TestFactory();
await future.save();

const middle = new Date('2025-02-10');
const url = `/tests?filter=createdAt__lt:${(middle.toISOString())}`;
const url = `${api}?filter=createdAt__lt:${(middle.toISOString())}`;
const res = await supertest(app.getHttpServer())
.get(url)

Expand Down
2 changes: 1 addition & 1 deletion lib/spec/test-app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ import { TestModule } from "./test.module";
TestModule
],
})
export class AppModule {};
export class AppModule {};
6 changes: 3 additions & 3 deletions lib/spec/test-app/test.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class TestController {
}) paginateParams: PaginateParams,
@SortAndFilter({
sortable: ['name', 'email'],
filterable: ['name', 'email', 'createdAt'],
filterable: ['name', 'email', 'someArray', 'createdAt'],
}) sortAndFilterParams: SortAndFilterParams,
): Promise<Paginated<Test>> {
return this.testRepository
Expand All @@ -32,9 +32,9 @@ export class TestController {
}) paginateParams: PaginateParams,
@SortAndFilter({
sortable: ['name', 'email'],
filterable: ['name', 'email', 'createdAt'],
filterable: ['name', 'email', 'someArray', 'createdAt'],
}) sortAndFilterParams: SortAndFilterParams,
): Promise<Paginated<Test>> {
return paginate(this.testRepository, paginateParams, sortAndFilter(sortAndFilterParams));
}
}
}
16 changes: 15 additions & 1 deletion lib/spec/test-app/test.entity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";

export enum SomeEnum {
Choice1 = '1',
Choice2 = '2',
Choice3 = '3',
};

@Entity()
export class Test extends BaseEntity {
@PrimaryGeneratedColumn()
Expand All @@ -11,9 +17,17 @@ export class Test extends BaseEntity {
@Column()
email: string;

@Column({
type: 'enum',
enum: SomeEnum,
array: true,
default: '{}'
})
someArray: SomeEnum[];

@UpdateDateColumn()
updatedAt: Date;

@CreateDateColumn()
createdAt: Date;
}
}
Loading