Skip to content

Commit 30a2509

Browse files
Merge pull request #57 from hivetown/fix/search
feat: category product's price and name search
2 parents b46e02c + 08bb1f0 commit 30a2509

File tree

7 files changed

+147
-63
lines changed

7 files changed

+147
-63
lines changed

src/controllers/category.ts

+14-26
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { NotFoundError } from '../errors/NotFoundError';
99
import { UniqueConstraintViolationException } from '@mikro-orm/core';
1010
import { ConflictError } from '../errors/ConflictError';
1111
import { Permission } from '../enums/Permission';
12+
import type { CategoryFilters } from '../interfaces/CategoryFilters';
1213

1314
@Controller('/categories')
1415
@Injectable()
@@ -17,7 +18,11 @@ export class CategoryController {
1718
validate({
1819
query: Joi.object({
1920
page: Joi.number().min(1),
20-
pageSize: Joi.number().min(1)
21+
pageSize: Joi.number().min(1),
22+
productMinPrice: Joi.number().min(0),
23+
productMaxPrice: Joi.number().min(0),
24+
productSearch: Joi.string(),
25+
parentId: Joi.number().min(1)
2126
})
2227
})
2328
])
@@ -27,7 +32,14 @@ export class CategoryController {
2732
size: Number(req.query.pageSize) || -1
2833
};
2934

30-
const items = await container.categoryGateway.findAllRoot(options);
35+
const filters: CategoryFilters = {
36+
productMinPrice: Number(req.query.productMinPrice) || undefined,
37+
productMaxPrice: Number(req.query.productMaxPrice) || undefined,
38+
productSearch: req.query.productSearch?.toString() || undefined,
39+
parentId: Number(req.query.parentId) || undefined
40+
};
41+
42+
const items = await container.categoryGateway.findAll(filters, options);
3143
return res.status(200).json(items);
3244
}
3345

@@ -114,30 +126,6 @@ export class CategoryController {
114126
return res.status(204).send();
115127
}
116128

117-
@Get('/:categoryId/categories', [
118-
validate({
119-
params: Joi.object({
120-
categoryId: Joi.number().min(1).required()
121-
}),
122-
query: Joi.object({
123-
page: Joi.number().min(1),
124-
pageSize: Joi.number().min(1)
125-
})
126-
})
127-
])
128-
public async categoryCategories(@Response() res: Express.Response, @Params('categoryId') categoryId: number, @Request() req: Express.Request) {
129-
const category = await container.categoryGateway.findById(categoryId);
130-
if (!category) throw new NotFoundError('Category not found');
131-
132-
const options: PaginatedOptions = {
133-
page: Number(req.query.page) || -1,
134-
size: Number(req.query.pageSize) || -1
135-
};
136-
137-
const items = await container.categoryGateway.findAllChildrenOfCategory(categoryId, options);
138-
return res.status(200).json(items);
139-
}
140-
141129
@Get('/:categoryId/fields', [
142130
validate({
143131
params: Joi.object({

src/controllers/products.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export class ProductsController {
2626
search: Joi.string().min(3),
2727
page: Joi.number().integer().min(1),
2828
pageSize: Joi.number().integer().min(1),
29-
field: Joi.object().pattern(/^'\d+'$/, Joi.array())
29+
field: Joi.object().pattern(/^'\d+'$/, Joi.array()),
30+
minPrice: Joi.number().integer().min(0),
31+
maxPrice: Joi.number().integer().min(0)
3032
})
3133
})
3234
])
@@ -53,6 +55,14 @@ export class ProductsController {
5355
filters.search = { value: req.query.search as string, type: StringSearchType.CONTAINS };
5456
}
5557

58+
if ('minPrice' in req.query) {
59+
filters.minPrice = Number(req.query.minPrice);
60+
}
61+
62+
if ('maxPrice' in req.query) {
63+
filters.maxPrice = Number(req.query.maxPrice);
64+
}
65+
5666
const options: ProductSpecOptions = {
5767
page: Number(req.query.page) || -1,
5868
size: Number(req.query.pageSize) || -1

src/gateways/CategoryGateway.ts

+59-28
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Category } from '../entities';
33
import type { BaseItems } from '../interfaces/BaseItems';
44
import type { PaginatedOptions } from '../interfaces/PaginationOptions';
55
import { paginate } from '../utils/paginate';
6+
import type { CategoryFilters } from '../interfaces/CategoryFilters';
67

78
export class CategoryGateway {
89
private repository: EntityRepository<Category>;
@@ -11,36 +12,66 @@ export class CategoryGateway {
1112
this.repository = orm.em.getRepository(Category);
1213
}
1314

14-
public async findAllRoot(options: PaginatedOptions): Promise<BaseItems<Category>> {
15+
public async findAll(filters: CategoryFilters, options: PaginatedOptions): Promise<BaseItems<Category>> {
1516
const pagination = paginate(options);
16-
const [categories, totalResults] = await Promise.all([
17-
this.repository
18-
.createQueryBuilder('category')
19-
.where('category.parent_id IS NULL')
20-
.leftJoinAndSelect('category.image', 'image')
21-
.limit(pagination.limit)
22-
.offset(pagination.offset)
23-
.getResult(),
24-
this.repository.createQueryBuilder('category').where('category.parent_id IS NULL').count()
25-
]);
26-
return {
27-
items: categories,
28-
totalItems: totalResults,
29-
totalPages: Math.ceil(totalResults / pagination.limit),
30-
page: Math.ceil(pagination.offset / pagination.limit) + 1,
31-
pageSize: categories.length
32-
};
33-
}
3417

35-
public async findAllChildrenOfCategory(categoryId: number, options: PaginatedOptions): Promise<BaseItems<Category>> {
36-
const pagination = paginate(options);
37-
const [categories, totalResults] = await Promise.all([
38-
this.repository.find(
39-
{ parent: categoryId },
40-
{ fields: ['name', 'parent.name', 'image'], limit: pagination.limit, offset: pagination.offset }
41-
),
42-
this.repository.count({ parent: categoryId })
43-
]);
18+
const parentFilterValues = [];
19+
if (filters.parentId) parentFilterValues.push(filters.parentId);
20+
const parentWhere = `category.parent_id ${parentFilterValues.length ? '= ?' : 'IS NULL'}`;
21+
const qb = this.repository.createQueryBuilder('category').where(parentWhere, parentFilterValues);
22+
23+
if (filters.productMaxPrice || filters.productMinPrice || filters.productSearch) {
24+
// Number() to prevent SQL injection
25+
const signal = ['>', '<']; // used for mapping
26+
const priceQuery = [Number(filters.productMinPrice), Number(filters.productMaxPrice)]
27+
.filter((v) => !Number.isNaN(v))
28+
.map((v, idx) => `producer_product.current_price ${signal[idx]}= ${v}`)
29+
.join(' AND ');
30+
31+
const productSearchFilterValues = [];
32+
if (filters.productSearch) productSearchFilterValues.push(`%${filters.productSearch}%`);
33+
34+
// We need to check if the current level (children, filhos) meets the criteria
35+
// or if any of the children's children (netos) meet the criteria.
36+
// We need to show every leaf of the tree, even if the actual leaf does not meet the criteria, for navigation:
37+
// we need the *parents to show a child that meets it.
38+
void qb.andWhere(
39+
// We check if the current category meets the criteria OR has a child that meets the criteria
40+
`category.id IN (SELECT product_spec_category.category_id
41+
FROM product_spec_category,
42+
producer_product,
43+
product_spec
44+
WHERE product_spec_category.product_spec_id = product_spec.id
45+
AND producer_product.product_spec_id = product_spec.id
46+
AND (${priceQuery.length ? `(${priceQuery})` : ''}
47+
${productSearchFilterValues.length ? `${priceQuery.length ? 'OR' : ''} product_spec.name LIKE ?` : ''}
48+
OR category.id IN (select category_inner.parent_id
49+
from (select *
50+
from category category_inner2
51+
order by parent_id, id) category_inner,
52+
(select @pv := category.id) initialisation
53+
where find_in_set(parent_id, @pv) > 0
54+
and @pv :=
55+
concat(@pv, ',', id)
56+
AND category_inner.id IN
57+
(SELECT product_spec_category_inner.category_id
58+
FROM product_spec_category product_spec_category_inner,
59+
producer_product producer_product_inner,
60+
product_spec product_spec_inner
61+
WHERE product_spec_category_inner.product_spec_id = product_spec.id
62+
AND producer_product_inner.product_spec_id = product_spec.id
63+
AND (${priceQuery.length ? `(${priceQuery})` : ''}
64+
${productSearchFilterValues.length ? `${priceQuery.length ? 'OR' : ''} product_spec.name LIKE ?` : ''})))))`,
65+
// Needs to be duplicated because we use it in two places (children and grandchildren)
66+
[...productSearchFilterValues, ...productSearchFilterValues]
67+
);
68+
}
69+
70+
const totalResultsQb = qb.clone();
71+
72+
void qb.leftJoinAndSelect('category.image', 'image').limit(pagination.limit).offset(pagination.offset);
73+
74+
const [categories, totalResults] = await Promise.all([qb.getResult(), totalResultsQb.count()]);
4475
return {
4576
items: categories,
4677
totalItems: totalResults,

src/gateways/ProductSpecGateway.ts

+53-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { EntityRepository, MikroORM, QueryBuilder } from '@mikro-orm/mysql';
1+
import type { EntityRepository, MikroORM, QueryBuilder, SelectQueryBuilder } from '@mikro-orm/mysql';
22
import { isEmpty } from 'lodash';
33
import { ProductSpec } from '../entities';
44
import type { BaseItems } from '../interfaces/BaseItems';
@@ -22,12 +22,40 @@ export class ProductSpecGateway {
2222
return this.repository.removeAndFlush(productSpec);
2323
}
2424

25-
public async findAll(filter?: ProductSpecFilters, options?: ProductSpecOptions): Promise<BaseItems<ProductSpec>> {
25+
public async findAll(
26+
filter?: ProductSpecFilters,
27+
options?: ProductSpecOptions
28+
): Promise<BaseItems<ProductSpec> | { minPrice: number; maxPrice: number }> {
2629
const pagination = paginate(options);
2730
const qb: QueryBuilder<ProductSpec> = this.repository.createQueryBuilder('spec').select('*');
2831

2932
if (filter?.categoryId) {
30-
void qb.leftJoin('spec.categories', 'specCategory').andWhere({ 'specCategory.category_id': filter.categoryId });
33+
// Number() to prevent SQL injection
34+
const categoryId = Number(filter.categoryId);
35+
36+
void qb.leftJoin('spec.categories', 'specCategory').andWhere(`
37+
(specCategory.category_id = ${categoryId} OR
38+
specCategory.category_id IN
39+
(select category.parent_id
40+
from (select *
41+
from category c
42+
order by parent_id, id) category,
43+
(select @pv := '${categoryId}') initialisation
44+
where find_in_set(parent_id, @pv) > 0
45+
and @pv := concat(@pv, ',', category.id)))
46+
`);
47+
}
48+
49+
if (filter?.minPrice) {
50+
void qb.leftJoin('spec.producerProducts', 'producerProduct').andWhere({
51+
'producerProduct.current_price': { $gte: filter.minPrice }
52+
});
53+
}
54+
55+
if (filter?.maxPrice) {
56+
void qb.leftJoin('spec.producerProducts', 'producerProduct').andWhere({
57+
'producerProduct.current_price': { $lte: filter.maxPrice }
58+
});
3159
}
3260

3361
if (filter?.search) {
@@ -48,7 +76,16 @@ export class ProductSpecGateway {
4876
}
4977

5078
// Calculate items count before grouping and paginating
51-
const totalItemsQb = qb.clone();
79+
const miscQb = qb
80+
.clone()
81+
.select('COUNT(*) as totalItems')
82+
.addSelect('MIN(producerProduct.current_price) as minPrice')
83+
.addSelect('MAX(producerProduct.current_price) as maxPrice')
84+
.leftJoin('spec.producerProducts', 'producerProduct') as unknown as SelectQueryBuilder<{
85+
totalItems: number;
86+
minPrice: number;
87+
maxPrice: number;
88+
}>;
5289

5390
// Add producers count, min and max price
5491
void qb
@@ -63,11 +100,20 @@ export class ProductSpecGateway {
63100
void qb.offset(pagination.offset).limit(pagination.limit);
64101

65102
// Fetch results and map them
66-
const [totalItems, productSpecs] = await Promise.all([totalItemsQb.getCount(), qb.getResultList()]);
103+
const [miscData, productSpecs] = await Promise.all([miscQb.execute('get'), qb.getResultList()]);
104+
console.log('\n\n\n\n\n\n', qb.getQuery(), '\n\n\n\n\n\n');
67105

68-
const totalPages = Math.ceil(totalItems / pagination.limit);
106+
const totalPages = Math.ceil(miscData.totalItems / pagination.limit);
69107
const page = Math.ceil(pagination.offset / pagination.limit) + 1;
70-
return { items: productSpecs, totalItems, totalPages, page, pageSize: productSpecs.length };
108+
return {
109+
items: productSpecs,
110+
totalItems: miscData.totalItems,
111+
totalPages,
112+
page,
113+
pageSize: productSpecs.length,
114+
maxPrice: miscData.maxPrice,
115+
minPrice: miscData.minPrice
116+
};
71117
}
72118

73119
public async findById(id: number): Promise<ProductSpec | null> {

src/interfaces/CategoryFilters.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export interface CategoryFilters {
2+
productMinPrice?: number;
3+
productMaxPrice?: number;
4+
productSearch?: string;
5+
parentId?: number;
6+
}

src/interfaces/ProductSpecFilters.ts

+2
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ export interface ProductSpecFilters {
99
*/
1010
fields?: { [key: number]: FieldTypeType[] };
1111
search?: StringSearch;
12+
minPrice?: number;
13+
maxPrice?: number;
1214
}

src/seeders/HivetownSeeder.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ export class HivetownSeeder extends Seeder {
6868
if (faker.datatype.number({ min: 1, max: 100 }) <= 5) return;
6969

7070
// The remaining 95% get a random parent
71-
const parent = faker.helpers.arrayElement(categories);
71+
// Limitation: the parent must have a lower id than the child
72+
const parent = faker.helpers.arrayElement(categories.filter((c) => c.id < category.id));
7273
if (parent.id !== category.id) category.parent = parent;
7374
});
7475

0 commit comments

Comments
 (0)