fix(medusa): add total count for list queries in product (#710)

This commit is contained in:
Sebastian Rindom
2021-11-03 13:15:11 +01:00
committed by GitHub
parent 075864f24c
commit 109d400720
5 changed files with 259 additions and 99 deletions
@@ -21,14 +21,12 @@ describe("GET /admin/products", () => {
it("returns 200 and decorated products", () => {
expect(subject.status).toEqual(200)
expect(subject.body.products[0]._id).toEqual(products.product1._id)
expect(subject.body.products[0].decorated).toEqual(true)
expect(subject.body.products[1]._id).toEqual(products.product2._id)
expect(subject.body.products[1].decorated).toEqual(true)
expect(subject.body.products[0].id).toEqual(products.product1.id)
expect(subject.body.products[1].id).toEqual(products.product2.id)
})
it("calls update", () => {
expect(ProductServiceMock.list).toHaveBeenCalledTimes(1)
expect(ProductServiceMock.listAndCount).toHaveBeenCalledTimes(1)
})
})
})
@@ -82,7 +82,10 @@ export default async (req, res) => {
take: limit,
}
const products = await productService.list(selector, listConfig)
const [products, count] = await productService.listAndCount(
selector,
listConfig
)
res.json({ products, count: products.length, offset, limit })
res.json({ products, count, offset, limit })
}
+126 -38
View File
@@ -20,45 +20,50 @@ type FindWithRelationsOptions = CustomOptions
@EntityRepository(Product)
export class ProductRepository extends Repository<Product> {
public async findWithRelations(
relations: Array<keyof Product> = [],
idsOrOptionsWithoutRelations: FindWithRelationsOptions = {}
): Promise<Product[]> {
private mergeEntitiesWithRelations(
entitiesAndRelations: Array<Partial<Product>>
): Product[] {
const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id")
return map(entitiesAndRelationsById, (entityAndRelations) =>
merge({}, ...entityAndRelations)
)
}
private async queryProducts(
optionsWithoutRelations: FindWithRelationsOptions,
shouldCount: boolean = false
): Promise<[Product[], number]> {
const tags = optionsWithoutRelations.where.tags
delete optionsWithoutRelations.where.tags
let qb = this.createQueryBuilder("product")
.select(["product.id"])
.where(optionsWithoutRelations.where)
.skip(optionsWithoutRelations.skip)
.take(optionsWithoutRelations.take)
.orderBy(optionsWithoutRelations.order)
if (tags) {
qb = qb
.leftJoinAndSelect("product.tags", "tags")
.andWhere(`tags.id IN (:...ids)`, { ids: tags._value })
}
let entities: Product[]
if (Array.isArray(idsOrOptionsWithoutRelations)) {
entities = await this.findByIds(idsOrOptionsWithoutRelations)
let count = null
if (shouldCount) {
const result = await qb.getManyAndCount()
entities = result[0]
count = result[1]
} else {
// Since tags are in a one-to-many realtion they cant be included in a
// regular query, to solve this add the join on tags seperately if
// the query exists
const tags = idsOrOptionsWithoutRelations.where.tags
delete idsOrOptionsWithoutRelations.where.tags
let qb = this.createQueryBuilder("product")
.select(["product.id"])
.where(idsOrOptionsWithoutRelations.where)
.skip(idsOrOptionsWithoutRelations.skip)
.take(idsOrOptionsWithoutRelations.take)
.orderBy(idsOrOptionsWithoutRelations.order)
if (tags) {
qb = qb
.leftJoinAndSelect("product.tags", "tags")
.andWhere(`tags.id IN (:...ids)`, { ids: tags._value })
}
entities = await qb.getMany()
}
const entitiesIds = entities.map(({ id }) => id)
if (entitiesIds.length === 0) {
// no need to continue
return []
}
if (relations.length === 0) {
return this.findByIds(entitiesIds, idsOrOptionsWithoutRelations)
}
return [entities, count]
}
private getGroupedRelations(relations: Array<keyof Product>): {
[toplevel: string]: string[]
} {
const groupedRelations: { [toplevel: string]: string[] } = {}
for (const rel of relations) {
const [topLevel] = rel.split(".")
@@ -69,6 +74,13 @@ export class ProductRepository extends Repository<Product> {
}
}
return groupedRelations
}
private async queryProductsWithIds(
entityIds: string[],
groupedRelations: { [toplevel: string]: string[] }
): Promise<Product[]> {
const entitiesIdsWithRelations = await Promise.all(
Object.entries(groupedRelations).map(([toplevel, rels]) => {
let querybuilder = this.createQueryBuilder("products")
@@ -105,18 +117,94 @@ export class ProductRepository extends Repository<Product> {
return querybuilder
.where(
"products.deleted_at IS NULL AND products.id IN (:...entitiesIds)",
{ entitiesIds }
{ entitiesIds: entityIds }
)
.getMany()
})
).then(flatten)
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
return entitiesIdsWithRelations
}
const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id")
return map(entitiesAndRelationsById, (entityAndRelations) =>
merge({}, ...entityAndRelations)
public async findWithRelationsAndCount(
relations: Array<keyof Product> = [],
idsOrOptionsWithoutRelations: FindWithRelationsOptions = {}
): Promise<[Product[], number]> {
let count: number
let entities: Product[]
if (Array.isArray(idsOrOptionsWithoutRelations)) {
entities = await this.findByIds(idsOrOptionsWithoutRelations)
count = entities.length
} else {
const result = await this.queryProducts(
idsOrOptionsWithoutRelations,
true
)
entities = result[0]
count = result[1]
}
const entitiesIds = entities.map(({ id }) => id)
if (entitiesIds.length === 0) {
// no need to continue
return [[], count]
}
if (relations.length === 0) {
const toReturn = await this.findByIds(
entitiesIds,
idsOrOptionsWithoutRelations
)
return [toReturn, toReturn.length]
}
const groupedRelations = this.getGroupedRelations(relations)
const entitiesIdsWithRelations = await this.queryProductsWithIds(
entitiesIds,
groupedRelations
)
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
const entitiesToReturn =
this.mergeEntitiesWithRelations(entitiesAndRelations)
return [entitiesToReturn, count]
}
public async findWithRelations(
relations: Array<keyof Product> = [],
idsOrOptionsWithoutRelations: FindWithRelationsOptions = {}
): Promise<Product[]> {
let entities: Product[]
if (Array.isArray(idsOrOptionsWithoutRelations)) {
entities = await this.findByIds(idsOrOptionsWithoutRelations)
} else {
const result = await this.queryProducts(
idsOrOptionsWithoutRelations,
false
)
entities = result[0]
}
const entitiesIds = entities.map(({ id }) => id)
if (entitiesIds.length === 0) {
// no need to continue
return []
}
if (relations.length === 0) {
return await this.findByIds(entitiesIds, idsOrOptionsWithoutRelations)
}
const groupedRelations = this.getGroupedRelations(relations)
const entitiesIdsWithRelations = await this.queryProductsWithIds(
entitiesIds,
groupedRelations
)
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
const entitiesToReturn =
this.mergeEntitiesWithRelations(entitiesAndRelations)
return entitiesToReturn
}
public async findOneWithRelations(
@@ -63,10 +63,10 @@ export const products = {
}
export const ProductServiceMock = {
withTransaction: function() {
withTransaction: function () {
return this
},
create: jest.fn().mockImplementation(data => {
create: jest.fn().mockImplementation((data) => {
if (data.title === "Test Product") {
return Promise.resolve(products.product1)
}
@@ -76,14 +76,14 @@ export const ProductServiceMock = {
return Promise.resolve({ ...data })
}),
count: jest.fn().mockReturnValue(4),
publish: jest.fn().mockImplementation(_ => {
publish: jest.fn().mockImplementation((_) => {
return Promise.resolve({
id: IdMap.getId("publish"),
name: "Product 1",
published: true,
})
}),
delete: jest.fn().mockImplementation(_ => {
delete: jest.fn().mockImplementation((_) => {
return Promise.resolve()
}),
createVariant: jest.fn().mockImplementation((productId, value) => {
@@ -111,7 +111,7 @@ export const ProductServiceMock = {
.mockReturnValue(
Promise.resolve([{ id: IdMap.getId("1") }, { id: IdMap.getId("2") }])
),
retrieve: jest.fn().mockImplementation(productId => {
retrieve: jest.fn().mockImplementation((productId) => {
if (productId === IdMap.getId("product1")) {
return Promise.resolve(products.product1)
}
@@ -135,7 +135,10 @@ export const ProductServiceMock = {
update: jest.fn().mockImplementation((product, data) => {
return Promise.resolve(products.product1)
}),
list: jest.fn().mockImplementation(data => {
listAndCount: jest.fn().mockImplementation((data) => {
return Promise.resolve([[products.product1, products.product2], 2])
}),
list: jest.fn().mockImplementation((data) => {
// Used to retrieve a product based on a variant id see
// ProductVariantService.addOptionValue
if (data.variants === IdMap.getId("giftCardVar")) {
+116 -48
View File
@@ -87,82 +87,86 @@ class ProductService extends BaseService {
}
/**
* @param {object} selector - selector for query
* @param {Object} config - config for query object for find
* @return {Promise} the result of the find operation
* Lists products based on the provided parameters.
* @param {object} selector - an object that defines rules to filter products
* by
* @param {object} config - object that defines the scope for what should be
* returned
* @return {Promise<Product[]>} the result of the find operation
*/
async list(selector = {}, config = { relations: [], skip: 0, take: 20 }) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
let q
if ("q" in selector) {
q = selector.q
delete selector.q
}
const query = this.buildQuery_(selector, config)
if (config.relations && config.relations.length > 0) {
query.relations = config.relations
}
if (config.select && config.select.length > 0) {
query.select = config.select
}
const rels = query.relations
delete query.relations
const { q, query, relations } = this.prepareListQuery_(selector, config)
if (q) {
const where = query.where
delete where.description
delete where.title
const raw = await productRepo
.createQueryBuilder("product")
.leftJoinAndSelect("product.variants", "variant")
.leftJoinAndSelect("product.collection", "collection")
.select(["product.id"])
.where(where)
.andWhere(
new Brackets((qb) => {
qb.where(`product.description ILIKE :q`, { q: `%${q}%` })
.orWhere(`product.title ILIKE :q`, { q: `%${q}%` })
.orWhere(`variant.title ILIKE :q`, { q: `%${q}%` })
.orWhere(`variant.sku ILIKE :q`, { q: `%${q}%` })
.orWhere(`collection.title ILIKE :q`, { q: `%${q}%` })
})
)
.getMany()
const qb = this.getFreeTextQueryBuilder_(productRepo, query, q)
const raw = await qb.getMany()
return productRepo.findWithRelations(
rels,
relations,
raw.map((i) => i.id)
)
}
return productRepo.findWithRelations(rels, query)
return productRepo.findWithRelations(relations, query)
}
/**
* Lists products based on the provided parameters and includes the count of
* products that match the query.
* @param {object} selector - an object that defines rules to filter products
* by
* @param {object} config - object that defines the scope for what should be
* returned
* @return {[Promise<Product[]>, number]} an array containing the products as
* the first element and the total count of products that matches the query
* as the second element.
*/
async listAndCount(
selector = {},
config = { relations: [], skip: 0, take: 20 }
) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
const { q, query, relations } = this.prepareListQuery_(selector, config)
if (q) {
const qb = this.getFreeTextQueryBuilder_(productRepo, query, q)
const [raw, count] = await qb.getManyAndCount()
const products = await productRepo.findWithRelations(
relations,
raw.map((i) => i.id)
)
return [products, count]
}
return await productRepo.findWithRelationsAndCount(relations, query)
}
/**
* Return the total number of documents in database
* @param {object} selector - the selector to choose products by
* @return {Promise} the result of the count operation
*/
count() {
count(selector = {}) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
return productRepo.count()
const query = this.buildQuery_(selector)
return productRepo.count(query)
}
/**
* Gets a product by id.
* Throws in case of DB Error and if product was not found.
* @param {string} productId - id of the product to get.
* @param {object} config - config of the product to get.
* @param {object} config - object that defines what should be included in the
* query response
* @return {Promise<Product>} the result of the find one operation.
*/
async retrieve(productId, config = {}) {
@@ -747,6 +751,70 @@ class ProductService extends BaseService {
// const final = await this.runDecorators_(decorated)
return product
}
/**
* Creates a query object to be used for list queries.
* @param {object} selector - the selector to create the query from
* @param {object} config - the config to use for the query
* @return {object} an object containing the query, relations and free-text
* search param.
*/
prepareListQuery_(selector, config) {
let q
if ("q" in selector) {
q = selector.q
delete selector.q
}
const query = this.buildQuery_(selector, config)
if (config.relations && config.relations.length > 0) {
query.relations = config.relations
}
if (config.select && config.select.length > 0) {
query.select = config.select
}
const rels = query.relations
delete query.relations
return {
query,
relations: rels,
q,
}
}
/**
* Creates a QueryBuilder that can fetch products based on free text.
* @param {ProductRepository} productRepo - an instance of a ProductRepositry
* @param {FindOptions<Product>} query - the query to get products by
* @param {string} q - the text to perform free text search from
* @return {QueryBuilder<Product>} a query builder that can fetch products
*/
getFreeTextQueryBuilder_(productRepo, query, q) {
const where = query.where
delete where.description
delete where.title
return productRepo
.createQueryBuilder("product")
.leftJoinAndSelect("product.variants", "variant")
.leftJoinAndSelect("product.collection", "collection")
.select(["product.id"])
.where(where)
.andWhere(
new Brackets((qb) => {
qb.where(`product.description ILIKE :q`, { q: `%${q}%` })
.orWhere(`product.title ILIKE :q`, { q: `%${q}%` })
.orWhere(`variant.title ILIKE :q`, { q: `%${q}%` })
.orWhere(`variant.sku ILIKE :q`, { q: `%${q}%` })
.orWhere(`collection.title ILIKE :q`, { q: `%${q}%` })
})
)
}
}
export default ProductService