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 { CatEntity } from './cat.entity'
import { SizeEmbed } from './size.embed'
import { ToyShopEntity } from './toy-shop.entity'
@Entity()
export class CatToyEntity {
@ -13,6 +14,10 @@ export class CatToyEntity {
@Column(() => SizeEmbed)
size: SizeEmbed
@ManyToOne(() => ToyShopEntity, { nullable: true })
@JoinColumn()
shop?: ToyShopEntity
@ManyToOne(() => CatEntity, (cat) => cat.toys)
@JoinColumn()
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
} else if ((isVirtualProperty && !query) || properties.isNested) {
if (properties.propertyName.includes('.')) {
const [nestedRel, nestedCol] = properties.propertyName.split('.')
return `${alias}_${properties.propertyPath}_rel_${nestedRel}_rel.${nestedCol}`
const propertyPath = properties.propertyName.split('.')
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 {
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 { PaginateQuery } from './decorator'
import { HttpException } from '@nestjs/common'
@ -16,6 +16,8 @@ import {
isSuffix,
OperatorSymbolToFunction,
} from './filter'
import { ToyShopEntity } from './__tests__/toy-shop.entity'
import { ToyShopAddressEntity } from './__tests__/toy-shop-address.entity'
const isoStringToDate = (isoString) => new Date(isoString)
@ -23,10 +25,15 @@ describe('paginate', () => {
let dataSource: DataSource
let catRepo: Repository<CatEntity>
let catToyRepo: Repository<CatToyEntity>
let toyShopRepo: Repository<ToyShopEntity>
let toyShopAddressRepository: Repository<ToyShopAddressEntity>
let catHomeRepo: Repository<CatHomeEntity>
let catHomePillowRepo: Repository<CatHomePillowEntity>
let cats: CatEntity[]
let catToys: CatToyEntity[]
let catToysWithoutShop: CatToyEntity[]
let toyShopsAddresses: ToyShopAddressEntity[]
let toysShops: ToyShopEntity[]
let catHomes: CatHomeEntity[]
let catHomePillows: CatHomePillowEntity[]
@ -47,13 +54,22 @@ describe('paginate', () => {
}),
synchronize: true,
logging: ['error'],
entities: [CatEntity, CatToyEntity, CatHomeEntity, CatHomePillowEntity],
entities: [
CatEntity,
CatToyEntity,
ToyShopAddressEntity,
CatHomeEntity,
CatHomePillowEntity,
ToyShopEntity,
],
})
await dataSource.initialize()
catRepo = dataSource.getRepository(CatEntity)
catToyRepo = dataSource.getRepository(CatToyEntity)
catHomeRepo = dataSource.getRepository(CatHomeEntity)
catHomePillowRepo = dataSource.getRepository(CatHomePillowEntity)
toyShopRepo = dataSource.getRepository(ToyShopEntity)
toyShopAddressRepository = dataSource.getRepository(ToyShopAddressEntity)
cats = await catRepo.save([
catRepo.create({
@ -97,12 +113,41 @@ describe('paginate', () => {
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([
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({ name: 'Mouse', cat: cats[0], size: { height: 6, width: 4, length: 13 } }),
catToyRepo.create({
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 } }),
])
catToysWithoutShop = catToys.map(({ shop: _, ...other }) => {
const newInstance = new CatToyEntity()
for (const otherKey in other) {
newInstance[otherKey] = other[otherKey]
}
return newInstance
})
catHomes = await catHomeRepo.save([
catHomeRepo.create({ name: 'Box', cat: cats[0] }),
catHomeRepo.create({ name: 'House', cat: cats[1] }),
@ -594,7 +639,7 @@ describe('paginate', () => {
const result = await paginate<CatToyEntity>(query, catToyRepo, config)
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')
})
@ -616,9 +661,9 @@ describe('paginate', () => {
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.meta.search).toStrictEqual('Mouse')
const toy = clone(catToys[1])
const toy = clone(catToysWithoutShop[1])
delete toy.cat
const toy2 = clone(catToys[2])
const toy2 = clone(catToysWithoutShop[2])
delete toy2.cat
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)
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')
})
it('should return result based on sort on one-to-many relation', async () => {
const config: PaginateConfig<CatEntity> = {
relations: ['toys'],
relations: ['toys', 'toys.shop', 'toys.shop.address'],
sortableColumns: ['id', 'name', 'toys.id'],
searchableColumns: ['name', 'toys.name'],
}
@ -684,6 +731,9 @@ describe('paginate', () => {
delete toy1.cat
const toy2 = clone(catToys[2])
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.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')
})
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 () => {
const config: PaginateConfig<CatToyEntity> = {
relations: ['cat'],
@ -908,9 +1055,9 @@ describe('paginate', () => {
const cat1 = clone(cats[0])
const cat2 = clone(cats[1])
const catToys1 = clone(catToys[0])
const catToys2 = clone(catToys[2])
const catToys3 = clone(catToys[3])
const catToys1 = clone(catToysWithoutShop[0])
const catToys2 = clone(catToysWithoutShop[2])
const catToys3 = clone(catToysWithoutShop[3])
delete catToys1.cat
delete catToys2.cat
delete catToys3.cat
@ -1062,7 +1209,7 @@ describe('paginate', () => {
copyCats[0].home = copyHomes[0]
copyCats[1].home = copyHomes[1]
const copyToys = catToys.map((toy: CatToyEntity) => {
const copyToys = catToysWithoutShop.map((toy: CatToyEntity) => {
const copy = clone(toy)
delete copy.cat
return copy
@ -1095,16 +1242,16 @@ describe('paginate', () => {
const result = await paginate<CatEntity>(query, catRepo, config)
const toy0 = clone(catToys[0])
const toy0 = clone(catToysWithoutShop[0])
delete toy0.cat
const toy1 = clone(catToys[1])
const toy1 = clone(catToysWithoutShop[1])
delete toy1.cat
const toy2 = clone(catToys[2])
const toy2 = clone(catToysWithoutShop[2])
delete toy2.cat
const toy3 = clone(catToys[3])
const toy3 = clone(catToysWithoutShop[3])
delete toy3.cat
const orderedCats = [
@ -1136,7 +1283,7 @@ describe('paginate', () => {
}
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.links.current).toBe(
@ -1279,7 +1426,12 @@ describe('paginate', () => {
const result = await paginate<CatToyEntity>(query, catToyRepo, config)
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')
})

View File

@ -1,32 +1,33 @@
import {
Brackets,
FindOperator,
FindOptionsRelationByString,
FindOptionsRelations,
FindOptionsUtils,
FindOptionsWhere,
ObjectLiteral,
Repository,
SelectQueryBuilder,
Brackets,
FindOptionsWhere,
FindOptionsRelations,
ObjectLiteral,
FindOptionsUtils,
FindOptionsRelationByString,
} from 'typeorm'
import { PaginateQuery } from './decorator'
import { ServiceUnavailableException, Logger } from '@nestjs/common'
import { Logger, ServiceUnavailableException } from '@nestjs/common'
import { mapKeys } from 'lodash'
import { stringify } from 'querystring'
import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause'
import {
checkIsRelation,
checkIsEmbedded,
checkIsRelation,
Column,
extractVirtualProperty,
fixColumnAlias,
getPropertiesByColumnName,
getQueryUrlComponents,
includesAllPrimaryKeyColumns,
isEntityKey,
Order,
positiveNumberOrDefault,
RelationColumn,
SortBy,
includesAllPrimaryKeyColumns,
isEntityKey,
getQueryUrlComponents,
} from './helper'
import { addFilter, FilterOperator, FilterSuffix } from './filter'
import { OrmUtils } from 'typeorm/util/OrmUtils'
@ -46,7 +47,9 @@ export class Paginated<T> {
searchBy: Column<T>[]
search: string
select: string[]
filter?: { [column: string]: string | string[] }
filter?: {
[column: string]: string | string[]
}
}
links: {
first?: string
@ -86,6 +89,47 @@ export const DEFAULT_MAX_LIMIT = 100
export const DEFAULT_LIMIT = 20
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>(
query: PaginateQuery,
repo: Repository<T> | SelectQueryBuilder<T>,
@ -199,8 +243,9 @@ export async function paginate<T extends ObjectLiteral>(
queryBuilder.select(cols)
}
if (config.where) {
queryBuilder.andWhere(new Brackets((qb) => qb.andWhere(config.where)))
if (config.where && repo instanceof Repository) {
const baseWhereStr = generateWhereStatement(queryBuilder, config.where)
queryBuilder.andWhere(`(${baseWhereStr})`)
}
if (config.withDeleted) {