fix: implemented a proper mechanism for using where condition in paginate options (#759)

This commit is contained in:
Vitalii Samofal 2023-09-25 13:58:04 +01:00 committed by GitHub
parent e62feb869f
commit a992f340c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 274 additions and 35 deletions

View File

@ -1,6 +1,7 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'
import { CatEntity } from './cat.entity' import { CatEntity } from './cat.entity'
import { SizeEmbed } from './size.embed' import { SizeEmbed } from './size.embed'
import { ToyShopEntity } from './toy-shop.entity'
@Entity() @Entity()
export class CatToyEntity { export class CatToyEntity {
@ -13,6 +14,10 @@ export class CatToyEntity {
@Column(() => SizeEmbed) @Column(() => SizeEmbed)
size: SizeEmbed size: SizeEmbed
@ManyToOne(() => ToyShopEntity, { nullable: true })
@JoinColumn()
shop?: ToyShopEntity
@ManyToOne(() => CatEntity, (cat) => cat.toys) @ManyToOne(() => CatEntity, (cat) => cat.toys)
@JoinColumn() @JoinColumn()
cat: CatEntity cat: CatEntity

View File

@ -0,0 +1,13 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity()
export class ToyShopAddressEntity {
@PrimaryGeneratedColumn()
id: number
@Column()
address: string
@CreateDateColumn()
createdAt: string
}

View File

@ -0,0 +1,18 @@
import { Column, CreateDateColumn, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'
import { ToyShopAddressEntity } from './toy-shop-address.entity'
@Entity()
export class ToyShopEntity {
@PrimaryGeneratedColumn()
id: number
@Column()
shopName: string
@OneToOne(() => ToyShopAddressEntity, { nullable: true })
@JoinColumn()
address: ToyShopAddressEntity
@CreateDateColumn()
createdAt: string
}

View File

@ -140,8 +140,14 @@ export function fixColumnAlias(
return `(${query(`${alias}_${properties.propertyPath}_rel`)})` // () is needed to avoid parameter conflict return `(${query(`${alias}_${properties.propertyPath}_rel`)})` // () is needed to avoid parameter conflict
} else if ((isVirtualProperty && !query) || properties.isNested) { } else if ((isVirtualProperty && !query) || properties.isNested) {
if (properties.propertyName.includes('.')) { if (properties.propertyName.includes('.')) {
const [nestedRel, nestedCol] = properties.propertyName.split('.') const propertyPath = properties.propertyName.split('.')
return `${alias}_${properties.propertyPath}_rel_${nestedRel}_rel.${nestedCol}` const nestedRelations = propertyPath
.slice(0, -1)
.map((v) => `${v}_rel`)
.join('_')
const nestedCol = propertyPath[propertyPath.length - 1]
return `${alias}_${properties.propertyPath}_rel_${nestedRelations}.${nestedCol}`
} else { } else {
return `${alias}_${properties.propertyPath}_rel_${properties.propertyName}` return `${alias}_${properties.propertyPath}_rel_${properties.propertyName}`
} }

View File

@ -1,4 +1,4 @@
import { Repository, In, DataSource, TypeORMError } from 'typeorm' import { Repository, In, DataSource, TypeORMError, Like } from 'typeorm'
import { Paginated, paginate, PaginateConfig, NO_PAGINATION } from './paginate' import { Paginated, paginate, PaginateConfig, NO_PAGINATION } from './paginate'
import { PaginateQuery } from './decorator' import { PaginateQuery } from './decorator'
import { HttpException } from '@nestjs/common' import { HttpException } from '@nestjs/common'
@ -16,6 +16,8 @@ import {
isSuffix, isSuffix,
OperatorSymbolToFunction, OperatorSymbolToFunction,
} from './filter' } from './filter'
import { ToyShopEntity } from './__tests__/toy-shop.entity'
import { ToyShopAddressEntity } from './__tests__/toy-shop-address.entity'
const isoStringToDate = (isoString) => new Date(isoString) const isoStringToDate = (isoString) => new Date(isoString)
@ -23,10 +25,15 @@ describe('paginate', () => {
let dataSource: DataSource let dataSource: DataSource
let catRepo: Repository<CatEntity> let catRepo: Repository<CatEntity>
let catToyRepo: Repository<CatToyEntity> let catToyRepo: Repository<CatToyEntity>
let toyShopRepo: Repository<ToyShopEntity>
let toyShopAddressRepository: Repository<ToyShopAddressEntity>
let catHomeRepo: Repository<CatHomeEntity> let catHomeRepo: Repository<CatHomeEntity>
let catHomePillowRepo: Repository<CatHomePillowEntity> let catHomePillowRepo: Repository<CatHomePillowEntity>
let cats: CatEntity[] let cats: CatEntity[]
let catToys: CatToyEntity[] let catToys: CatToyEntity[]
let catToysWithoutShop: CatToyEntity[]
let toyShopsAddresses: ToyShopAddressEntity[]
let toysShops: ToyShopEntity[]
let catHomes: CatHomeEntity[] let catHomes: CatHomeEntity[]
let catHomePillows: CatHomePillowEntity[] let catHomePillows: CatHomePillowEntity[]
@ -47,13 +54,22 @@ describe('paginate', () => {
}), }),
synchronize: true, synchronize: true,
logging: ['error'], logging: ['error'],
entities: [CatEntity, CatToyEntity, CatHomeEntity, CatHomePillowEntity], entities: [
CatEntity,
CatToyEntity,
ToyShopAddressEntity,
CatHomeEntity,
CatHomePillowEntity,
ToyShopEntity,
],
}) })
await dataSource.initialize() await dataSource.initialize()
catRepo = dataSource.getRepository(CatEntity) catRepo = dataSource.getRepository(CatEntity)
catToyRepo = dataSource.getRepository(CatToyEntity) catToyRepo = dataSource.getRepository(CatToyEntity)
catHomeRepo = dataSource.getRepository(CatHomeEntity) catHomeRepo = dataSource.getRepository(CatHomeEntity)
catHomePillowRepo = dataSource.getRepository(CatHomePillowEntity) catHomePillowRepo = dataSource.getRepository(CatHomePillowEntity)
toyShopRepo = dataSource.getRepository(ToyShopEntity)
toyShopAddressRepository = dataSource.getRepository(ToyShopAddressEntity)
cats = await catRepo.save([ cats = await catRepo.save([
catRepo.create({ catRepo.create({
@ -97,12 +113,41 @@ describe('paginate', () => {
size: { height: 10, width: 5, length: 15 }, size: { height: 10, width: 5, length: 15 },
}), }),
]) ])
toyShopsAddresses = await toyShopAddressRepository.save([
toyShopAddressRepository.create({ address: '123 Main St' }),
])
toysShops = await toyShopRepo.save([
toyShopRepo.create({ shopName: 'Best Toys', address: toyShopsAddresses[0] }),
toyShopRepo.create({ shopName: 'Lovely Toys' }),
])
catToys = await catToyRepo.save([ catToys = await catToyRepo.save([
catToyRepo.create({ name: 'Fuzzy Thing', cat: cats[0], size: { height: 10, width: 10, length: 10 } }), catToyRepo.create({ name: 'Fuzzy Thing', cat: cats[0], size: { height: 10, width: 10, length: 10 } }),
catToyRepo.create({ name: 'Stuffed Mouse', cat: cats[0], size: { height: 5, width: 5, length: 12 } }), catToyRepo.create({
catToyRepo.create({ name: 'Mouse', cat: cats[0], size: { height: 6, width: 4, length: 13 } }), name: 'Stuffed Mouse',
shop: toysShops[0],
cat: cats[0],
size: { height: 5, width: 5, length: 12 },
}),
catToyRepo.create({
name: 'Mouse',
shop: toysShops[1],
cat: cats[0],
size: { height: 6, width: 4, length: 13 },
}),
catToyRepo.create({ name: 'String', cat: cats[1], size: { height: 1, width: 1, length: 50 } }), catToyRepo.create({ name: 'String', cat: cats[1], size: { height: 1, width: 1, length: 50 } }),
]) ])
catToysWithoutShop = catToys.map(({ shop: _, ...other }) => {
const newInstance = new CatToyEntity()
for (const otherKey in other) {
newInstance[otherKey] = other[otherKey]
}
return newInstance
})
catHomes = await catHomeRepo.save([ catHomes = await catHomeRepo.save([
catHomeRepo.create({ name: 'Box', cat: cats[0] }), catHomeRepo.create({ name: 'Box', cat: cats[0] }),
catHomeRepo.create({ name: 'House', cat: cats[1] }), catHomeRepo.create({ name: 'House', cat: cats[1] }),
@ -594,7 +639,7 @@ describe('paginate', () => {
const result = await paginate<CatToyEntity>(query, catToyRepo, config) const result = await paginate<CatToyEntity>(query, catToyRepo, config)
expect(result.meta.search).toStrictEqual('Milo') expect(result.meta.search).toStrictEqual('Milo')
expect(result.data).toStrictEqual([catToys[0], catToys[1], catToys[2]]) expect(result.data).toStrictEqual([catToysWithoutShop[0], catToysWithoutShop[1], catToysWithoutShop[2]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&search=Milo') expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&search=Milo')
}) })
@ -616,9 +661,9 @@ describe('paginate', () => {
const result = await paginate<CatEntity>(query, catRepo, config) const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.meta.search).toStrictEqual('Mouse') expect(result.meta.search).toStrictEqual('Mouse')
const toy = clone(catToys[1]) const toy = clone(catToysWithoutShop[1])
delete toy.cat delete toy.cat
const toy2 = clone(catToys[2]) const toy2 = clone(catToysWithoutShop[2])
delete toy2.cat delete toy2.cat
expect(result.data).toStrictEqual([Object.assign(clone(cats[0]), { toys: [toy2, toy] })]) expect(result.data).toStrictEqual([Object.assign(clone(cats[0]), { toys: [toy2, toy] })])
@ -661,13 +706,15 @@ describe('paginate', () => {
const result = await paginate<CatToyEntity>(query, catToyRepo, config) const result = await paginate<CatToyEntity>(query, catToyRepo, config)
expect(result.meta.search).toStrictEqual('Milo') expect(result.meta.search).toStrictEqual('Milo')
expect(result.data).toStrictEqual([catToys[0], catToys[1], catToys[2]].sort((a, b) => b.cat.id - a.cat.id)) expect(result.data).toStrictEqual(
[catToysWithoutShop[0], catToysWithoutShop[1], catToysWithoutShop[2]].sort((a, b) => b.cat.id - a.cat.id)
)
expect(result.links.current).toBe('?page=1&limit=20&sortBy=cat.id:DESC&search=Milo') expect(result.links.current).toBe('?page=1&limit=20&sortBy=cat.id:DESC&search=Milo')
}) })
it('should return result based on sort on one-to-many relation', async () => { it('should return result based on sort on one-to-many relation', async () => {
const config: PaginateConfig<CatEntity> = { const config: PaginateConfig<CatEntity> = {
relations: ['toys'], relations: ['toys', 'toys.shop', 'toys.shop.address'],
sortableColumns: ['id', 'name', 'toys.id'], sortableColumns: ['id', 'name', 'toys.id'],
searchableColumns: ['name', 'toys.name'], searchableColumns: ['name', 'toys.name'],
} }
@ -684,6 +731,9 @@ describe('paginate', () => {
delete toy1.cat delete toy1.cat
const toy2 = clone(catToys[2]) const toy2 = clone(catToys[2])
delete toy2.cat delete toy2.cat
delete result.data[0].toys[0].shop.address
expect(result.data).toStrictEqual([Object.assign(clone(cats[0]), { toys: [toy2, toy1] })]) expect(result.data).toStrictEqual([Object.assign(clone(cats[0]), { toys: [toy2, toy1] })])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=toys.id:DESC&search=Mouse') expect(result.links.current).toBe('?page=1&limit=20&sortBy=toys.id:DESC&search=Mouse')
}) })
@ -865,6 +915,103 @@ describe('paginate', () => {
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC') expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC')
}) })
it('should return valid data filtering by not id field many-to-one', async () => {
const config: PaginateConfig<CatToyEntity> = {
sortableColumns: ['id', 'name'],
relations: ['cat'],
where: {
cat: {
name: cats[0].name,
},
},
}
const query: PaginateQuery = {
path: '',
}
const result = await paginate<CatToyEntity>(query, catToyRepo, config)
expect(result.meta.totalItems).toBe(3)
result.data.forEach((toy) => {
expect(toy.cat.id).toBe(cats[0].id)
})
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC')
})
it('should return result based on where one-to-many relation', async () => {
const config: PaginateConfig<CatEntity> = {
relations: ['toys'],
sortableColumns: ['id', 'name'],
where: {
toys: {
name: 'Stuffed Mouse',
},
},
}
const query: PaginateQuery = {
path: '',
}
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data.length).toBe(1)
expect(result.data[0].toys.length).toBe(1)
expect(result.data[0].toys[0].name).toBe('Stuffed Mouse')
})
it('should return all cats with a toys from the lovely shop', async () => {
const config: PaginateConfig<CatEntity> = {
relations: ['toys', 'toys.shop'],
sortableColumns: ['id', 'name'],
where: {
toys: {
shop: {
shopName: 'Lovely Toys',
},
},
},
}
const query: PaginateQuery = {
path: '',
}
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data.length).toBe(1)
expect(result.data[0].toys.length).toBe(1)
expect(result.data[0].toys[0].shop.id).toStrictEqual(toysShops[1].id)
expect(result.data[0].toys[0].name).toBe('Mouse')
})
it('should return all cats from shop where street name like 123', async () => {
const config: PaginateConfig<CatEntity> = {
relations: ['toys', 'toys.shop', 'toys.shop.address'],
sortableColumns: ['id', 'name'],
where: {
toys: {
shop: {
address: {
address: Like('%123%'),
},
},
},
},
}
const query: PaginateQuery = {
path: '',
}
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data.length).toBe(1)
expect(result.data[0].toys.length).toBe(1)
expect(result.data[0].toys[0].shop).toStrictEqual(toysShops[0])
expect(result.data[0].toys[0].name).toBe('Stuffed Mouse')
})
it('should return result based on filter on many-to-one relation', async () => { it('should return result based on filter on many-to-one relation', async () => {
const config: PaginateConfig<CatToyEntity> = { const config: PaginateConfig<CatToyEntity> = {
relations: ['cat'], relations: ['cat'],
@ -908,9 +1055,9 @@ describe('paginate', () => {
const cat1 = clone(cats[0]) const cat1 = clone(cats[0])
const cat2 = clone(cats[1]) const cat2 = clone(cats[1])
const catToys1 = clone(catToys[0]) const catToys1 = clone(catToysWithoutShop[0])
const catToys2 = clone(catToys[2]) const catToys2 = clone(catToysWithoutShop[2])
const catToys3 = clone(catToys[3]) const catToys3 = clone(catToysWithoutShop[3])
delete catToys1.cat delete catToys1.cat
delete catToys2.cat delete catToys2.cat
delete catToys3.cat delete catToys3.cat
@ -1062,7 +1209,7 @@ describe('paginate', () => {
copyCats[0].home = copyHomes[0] copyCats[0].home = copyHomes[0]
copyCats[1].home = copyHomes[1] copyCats[1].home = copyHomes[1]
const copyToys = catToys.map((toy: CatToyEntity) => { const copyToys = catToysWithoutShop.map((toy: CatToyEntity) => {
const copy = clone(toy) const copy = clone(toy)
delete copy.cat delete copy.cat
return copy return copy
@ -1095,16 +1242,16 @@ describe('paginate', () => {
const result = await paginate<CatEntity>(query, catRepo, config) const result = await paginate<CatEntity>(query, catRepo, config)
const toy0 = clone(catToys[0]) const toy0 = clone(catToysWithoutShop[0])
delete toy0.cat delete toy0.cat
const toy1 = clone(catToys[1]) const toy1 = clone(catToysWithoutShop[1])
delete toy1.cat delete toy1.cat
const toy2 = clone(catToys[2]) const toy2 = clone(catToysWithoutShop[2])
delete toy2.cat delete toy2.cat
const toy3 = clone(catToys[3]) const toy3 = clone(catToysWithoutShop[3])
delete toy3.cat delete toy3.cat
const orderedCats = [ const orderedCats = [
@ -1136,7 +1283,7 @@ describe('paginate', () => {
} }
const result = await paginate<CatToyEntity>(query, catToyRepo, config) const result = await paginate<CatToyEntity>(query, catToyRepo, config)
const orderedToys = [catToys[3], catToys[0], catToys[2], catToys[1]] const orderedToys = [catToysWithoutShop[3], catToysWithoutShop[0], catToysWithoutShop[2], catToysWithoutShop[1]]
expect(result.data).toStrictEqual(orderedToys) expect(result.data).toStrictEqual(orderedToys)
expect(result.links.current).toBe( expect(result.links.current).toBe(
@ -1279,7 +1426,12 @@ describe('paginate', () => {
const result = await paginate<CatToyEntity>(query, catToyRepo, config) const result = await paginate<CatToyEntity>(query, catToyRepo, config)
expect(result.meta.search).toStrictEqual('1') expect(result.meta.search).toStrictEqual('1')
expect(result.data).toStrictEqual([catToys[3], catToys[0], catToys[1], catToys[2]]) expect(result.data).toStrictEqual([
catToysWithoutShop[3],
catToysWithoutShop[0],
catToysWithoutShop[1],
catToysWithoutShop[2],
])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=cat.(size.height):DESC&search=1') expect(result.links.current).toBe('?page=1&limit=20&sortBy=cat.(size.height):DESC&search=1')
}) })

View File

@ -1,32 +1,33 @@
import { import {
Brackets,
FindOperator,
FindOptionsRelationByString,
FindOptionsRelations,
FindOptionsUtils,
FindOptionsWhere,
ObjectLiteral,
Repository, Repository,
SelectQueryBuilder, SelectQueryBuilder,
Brackets,
FindOptionsWhere,
FindOptionsRelations,
ObjectLiteral,
FindOptionsUtils,
FindOptionsRelationByString,
} from 'typeorm' } from 'typeorm'
import { PaginateQuery } from './decorator' import { PaginateQuery } from './decorator'
import { ServiceUnavailableException, Logger } from '@nestjs/common' import { Logger, ServiceUnavailableException } from '@nestjs/common'
import { mapKeys } from 'lodash' import { mapKeys } from 'lodash'
import { stringify } from 'querystring' import { stringify } from 'querystring'
import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause' import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause'
import { import {
checkIsRelation,
checkIsEmbedded, checkIsEmbedded,
checkIsRelation,
Column, Column,
extractVirtualProperty, extractVirtualProperty,
fixColumnAlias, fixColumnAlias,
getPropertiesByColumnName, getPropertiesByColumnName,
getQueryUrlComponents,
includesAllPrimaryKeyColumns,
isEntityKey,
Order, Order,
positiveNumberOrDefault, positiveNumberOrDefault,
RelationColumn, RelationColumn,
SortBy, SortBy,
includesAllPrimaryKeyColumns,
isEntityKey,
getQueryUrlComponents,
} from './helper' } from './helper'
import { addFilter, FilterOperator, FilterSuffix } from './filter' import { addFilter, FilterOperator, FilterSuffix } from './filter'
import { OrmUtils } from 'typeorm/util/OrmUtils' import { OrmUtils } from 'typeorm/util/OrmUtils'
@ -46,7 +47,9 @@ export class Paginated<T> {
searchBy: Column<T>[] searchBy: Column<T>[]
search: string search: string
select: string[] select: string[]
filter?: { [column: string]: string | string[] } filter?: {
[column: string]: string | string[]
}
} }
links: { links: {
first?: string first?: string
@ -86,6 +89,47 @@ export const DEFAULT_MAX_LIMIT = 100
export const DEFAULT_LIMIT = 20 export const DEFAULT_LIMIT = 20
export const NO_PAGINATION = 0 export const NO_PAGINATION = 0
function generateWhereStatement<T>(
queryBuilder: SelectQueryBuilder<T>,
obj: FindOptionsWhere<T> | FindOptionsWhere<T>[]
) {
const toTransform = Array.isArray(obj) ? obj : [obj]
return toTransform.map((item) => flattenWhereAndTransform(queryBuilder, item).join(' AND ')).join(' OR ')
}
function flattenWhereAndTransform<T>(
queryBuilder: SelectQueryBuilder<T>,
obj: FindOptionsWhere<T>,
separator = '.',
parentKey = ''
) {
return Object.entries(obj).flatMap(([key, value]) => {
if (obj.hasOwnProperty(key)) {
const joinedKey = parentKey ? `${parentKey}${separator}${key}` : key
if (typeof value === 'object' && value !== null && !(value instanceof FindOperator)) {
return flattenWhereAndTransform(queryBuilder, value as FindOptionsWhere<T>, separator, joinedKey)
} else {
const property = getPropertiesByColumnName(joinedKey)
const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(queryBuilder, property)
const isRelation = checkIsRelation(queryBuilder, property.propertyPath)
const isEmbedded = checkIsEmbedded(queryBuilder, property.propertyPath)
const alias = fixColumnAlias(
property,
queryBuilder.alias,
isRelation,
isVirtualProperty,
isEmbedded,
virtualQuery
)
return queryBuilder['createWhereConditionExpression'](
queryBuilder['getWherePredicateCondition'](alias, value)
)
}
}
})
}
export async function paginate<T extends ObjectLiteral>( export async function paginate<T extends ObjectLiteral>(
query: PaginateQuery, query: PaginateQuery,
repo: Repository<T> | SelectQueryBuilder<T>, repo: Repository<T> | SelectQueryBuilder<T>,
@ -199,8 +243,9 @@ export async function paginate<T extends ObjectLiteral>(
queryBuilder.select(cols) queryBuilder.select(cols)
} }
if (config.where) { if (config.where && repo instanceof Repository) {
queryBuilder.andWhere(new Brackets((qb) => qb.andWhere(config.where))) const baseWhereStr = generateWhereStatement(queryBuilder, config.where)
queryBuilder.andWhere(`(${baseWhereStr})`)
} }
if (config.withDeleted) { if (config.withDeleted) {