Skip to content

Commit

Permalink
feat(endpoint): add count is allow distinct (#8)
Browse files Browse the repository at this point in the history
* feat(endpoint): add count is allow distinct

* feat(endpoint): add is allow distinct default value

* feat(endpoint): add tests

* feat(endpoint): update tests

* feat(endpoint): update count distinct (#9)

* feat(endpoint): update count distinct

* feat(endpoint): update deps

* feat(endpoint): add supportive count with complex distinct

* refactor(endpoint): decompose count handler

* feat(endpoint): clean up

* feat(endpoint): revert has empty condition reg exp

* feat(endpoint): update distinct count source manager

* feat(endpoint): clean up

* feat(endpoint): clean up
  • Loading branch information
OlegDO authored Dec 13, 2023
1 parent ee5e7a1 commit 638b711
Show file tree
Hide file tree
Showing 4 changed files with 123 additions and 59 deletions.
67 changes: 42 additions & 25 deletions __tests__/services/endpoint-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,29 +107,12 @@ describe('services/endpoint', () => {
expect(result).to.deep.equal({ ...countResult(), payloadParam: 1 });
});

it('should correctly build default params with distinct: count', async () => {
// return typeorm from handler
handler.callsFake((query) => query);

const result = await countHandler(
{
query: {
distinct: 'param',
},
},
endpointOptions,
);
const [queryBuilder, params] = defaultHandlerStub.firstCall.args;

expect(queryBuilder).to.be.instanceof(SelectQueryBuilder);
expect(params?.distinct).to.be.equal('param');
expect(result).to.deep.equal({ ...countResult() });
});

it('handler - should return count entities without removed: default handler', async () => {
defaultHandlerStub.restore();

const result = await Endpoint.defaultHandler.count(repository.createQueryBuilder());
const result = await Endpoint.defaultHandler.count(repository.createQueryBuilder(), {
repository,
});

expect(TypeormMock.queryBuilder.getCount).to.be.calledOnce;
expect(result).to.deep.equal({ count: 0 });
Expand All @@ -138,31 +121,65 @@ describe('services/endpoint', () => {
it('handler - should return count entities with removed: default handler', async () => {
const qb = repository.createQueryBuilder();
const withDeletedSpy = sandbox.spy(qb, 'withDeleted');
const result = await Endpoint.defaultHandler.count(qb, { hasRemoved: true });
const result = await Endpoint.defaultHandler.count(qb, { repository, hasRemoved: true });

expect(withDeletedSpy).to.be.calledOnce;
expect(TypeormMock.queryBuilder.getCount).to.be.calledOnce;
expect(result).to.deep.equal({ count: 0 });
});

it('handler - should return raw count entities', async () => {
it('handler - should return non-raw count entities', async () => {
const qb = repository.createQueryBuilder();
const result = await Endpoint.defaultHandler.count(qb, { distinct: 'param' });
const result = await Endpoint.defaultHandler.count(qb, {
isAllowDistinct: false,
repository,
});

const [query, params] = qb.getQueryAndParameters();

expect(query).to.equal(
'SELECT COUNT(DISTINCT "param")::integer AS "count" FROM "test_entity" "TestEntity"',
'SELECT "TestEntity"."id" AS "TestEntity_id", "TestEntity"."param" AS "TestEntity_param", "TestEntity"."param2" AS "TestEntity_param2" FROM "test_entity" "TestEntity"',
);
expect(TypeormMock.entityManager.createQueryBuilder).to.calledOnce;
expect(params).to.deep.equal([]);
expect(TypeormMock.queryBuilder.getCount).to.be.calledOnce;
expect(TypeormMock.queryBuilder.getRawOne).to.be.not.called;
expect(result).to.deep.equal({ count: 0 });
});

it('handler - should return raw count entities', async () => {
const qb = repository.createQueryBuilder().select(['id', 'param']).distinctOn(['param']);
const result = await Endpoint.defaultHandler.count(qb, {
isAllowDistinct: true,
repository,
});

const [originalQuery, originalParams] = qb.getQueryAndParameters();

expect(originalQuery).to.equal(
'SELECT DISTINCT ON (param) id, param FROM "test_entity" "TestEntity"',
);
expect(TypeormMock.entityManager.createQueryBuilder).to.calledTwice;
expect(originalParams).to.deep.equal([]);
expect(TypeormMock.queryBuilder.getCount).to.be.not.called;
expect(TypeormMock.queryBuilder.getRawOne).to.be.calledOnce;
expect(result).to.deep.equal({ count: 0 });
});

it('handler - should throw error distinct is now allowed', async () => {
expect(
await waitResult(
Endpoint.defaultHandler.count(
repository.createQueryBuilder().select(['id', 'param']).distinctOn(['param']),
{ repository },
),
),
).to.throw('Distinct select is not allowed.');
});

it('should run default count handler with query builder: typeorm case - cache', async () => {
const qb = repository.createQueryBuilder();
const res = await Endpoint.defaultHandler.count(qb, { cache: 100 });
const res = await Endpoint.defaultHandler.count(qb, { repository, cache: 100 });

expect(TypeormMock.queryBuilder.getCount).to.be.calledOnce;
expect(qb.expressionMap.cache).to.be.ok;
Expand Down
32 changes: 16 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
"@lomray/microservice-nodejs-lib": "^2.21.2",
"@lomray/microservice-remote-middleware": "^1.8.2",
"@lomray/microservices-client-api": "^2.28.0",
"@lomray/microservices-types": "^1.14.0",
"@lomray/typeorm-json-query": "^2.5.5",
"@lomray/microservices-types": "^1.15.0",
"@lomray/typeorm-json-query": "^2.6.0",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.45.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.45.1",
Expand Down
79 changes: 63 additions & 16 deletions src/services/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enum CRUD_EXCEPTION_CODE {
FAILED_RESTORE = -33486,
ENTITY_NOT_FOUND = -33487,
ENTITY_ALREADY_EXIST = -33488,
DISTINCT_SELECT_FORBIDDEN = -33489,
}

export type Constructable<TParams = any> = new (...args: any[]) => TParams;
Expand Down Expand Up @@ -97,6 +98,7 @@ export type IRequestPayload<TEntity, TPayload> = TPayload & {
isParallel?: boolean;
shouldReturnEntity?: boolean;
shouldResetCache?: boolean;
isAllowDistinct?: boolean;
};
options?: Partial<ITypeormJsonQueryOptions>;
query?: IJsonQuery<TEntity>;
Expand All @@ -110,8 +112,9 @@ export interface IGetQueryParams {
[key: string]: any;
}

export interface IGetQueryCountParams extends IGetQueryParams {
distinct?: string;
export interface IGetQueryCountParams<TEntity> extends IGetQueryParams {
repository: Repository<TEntity>;
isAllowDistinct?: boolean;
}

export interface IGetQueryListParams extends Pick<IGetQueryParams, 'hasRemoved'> {
Expand Down Expand Up @@ -334,6 +337,7 @@ export interface ICrudParams<TEntity, TParams = ObjectLiteral, TResult = ObjectL
export interface ICountParams<TEntity, TParams, TResult>
extends ICrudParams<TEntity, TParams, TResult> {
cache?: number;
isAllowDistinct?: boolean;
}

export interface IListParams<TEntity, TParams, TResult>
Expand Down Expand Up @@ -597,26 +601,69 @@ const defaultHandler = <TEntity>(query: TypeormJsonQuery<TEntity>): TypeormJsonQ
*/
const getQueryCount = async <TEntity>(
query: SelectQueryBuilder<TEntity>,
{ hasRemoved = false, cache = 0, distinct }: IGetQueryCountParams = {},
{
repository,
hasRemoved = false,
cache = 0,
isAllowDistinct = false,
}: IGetQueryCountParams<TEntity>,
): Promise<CountOutputParams> => {
if (hasRemoved) {
query.withDeleted();
}

// Apply distinct select
if (distinct) {
query.select(`COUNT(DISTINCT "${distinct}")::integer`, 'count');
if (query.getQuery().includes('DISTINCT')) {
return { count: await getQueryCountWithDistinct(query, repository, isAllowDistinct, cache) };
}

return { count: await getQueryCountWithoutDistinct(query, cache) };
};

/**
* Returns count without distinct
*/
const getQueryCountWithoutDistinct = <TEntity>(
query: SelectQueryBuilder<TEntity>,
cache: number,
): Promise<number> => {
if (cache) {
// Disable is only where condition
query.cache(getCrudCacheKey(query, CACHE_KEYS.count, { hasOnlyWhere: !distinct }), cache);
query.cache(getCrudCacheKey(query, CACHE_KEYS.count, { hasOnlyWhere: true }), cache);
}

return {
// Returns raw count if distinct enabled
count: distinct ? (await query.getRawOne())?.count || 0 : await query.getCount(),
};
return query.getCount();
};

/**
* Returns count with distinct
*/
const getQueryCountWithDistinct = async <TEntity>(
query: SelectQueryBuilder<TEntity>,
repository: Repository<TEntity>,
isAllowDistinct: boolean,
cache: number,
): Promise<number> => {
if (!isAllowDistinct) {
throw new BaseException({
code: CRUD_EXCEPTION_CODE.DISTINCT_SELECT_FORBIDDEN,
status: 422,
message: 'Distinct select is not allowed.',
});
}

if (cache) {
query.cache(getCrudCacheKey(query, CACHE_KEYS.count, { hasOnlyWhere: false }), cache);
}

// Build result query
const resultQuery = repository.createQueryBuilder().select('COUNT(sub.*)::integer', 'result');

// Override result query expressions for preventing select from entity and then from sub query
resultQuery.expressionMap.aliases = [];

// Add json query sub query as source
resultQuery.from(`(${query.getQuery()})`, 'sub');

return (await resultQuery.getRawOne<{ result: number }>())?.result ?? 0;
};

/**
Expand Down Expand Up @@ -1161,20 +1208,20 @@ class Endpoint {
> {
const countHandler: IReturn<TEntity, TParams, TPayload, CountOutputParams | TResult> =
async function (params, options) {
const { repository, queryOptions, cache } = countOptions();
const { repository, queryOptions, cache, isAllowDistinct } = countOptions();
const typeQuery = createTypeQuery(repository.createQueryBuilder(), params, {
relationOptions: ['*'],
isDisableOrderBy: true,
isDisableAttributes: true,
...queryOptions,
});
const result = await handler(typeQuery, params, options);
const { hasRemoved, query: iJsonQuery } = params;
const { hasRemoved } = params;
const defaultParams = {
hasRemoved,
cache,
// Check and cast to string from TEntity field
...(typeof iJsonQuery?.distinct === 'string' ? { distinct: iJsonQuery.distinct } : {}),
isAllowDistinct,
repository,
};

if (result instanceof TypeormJsonQuery) {
Expand Down

0 comments on commit 638b711

Please sign in to comment.