fix(medusa): add total count for list queries in product (#710)
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user