diff --git a/README.md b/README.md index 5577dab..fa70943 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Pagination and filtering helper method for TypeORM repositories or query builder - Search across columns - Select columns - Filter using operators (`$eq`, `$not`, `$null`, `$in`, `$gt`, `$gte`, `$lt`, `$lte`, `$btw`, `$ilike`, `$sw`) -- Include relations +- Include relations and nested relations - Virtual column support ## Installation @@ -351,6 +351,31 @@ const config: PaginateConfig = { const result = await paginate(query, catRepo, config) ``` +## Usage with nested Relations + +Similar as with relations, you can specify nested relations for sorting, filtering, search and relations: + +### Example + +#### Endpoint + +```url +http://localhost:3000/cats?filter.home.pillows.color=$eq:ping,String +``` + +#### Code + +```typescript +const config: PaginateConfig = { + relations: { home: { pillows: true } }, + sortableColumns: ['id', 'name', 'home.pillows.color'], + searchableColumns: ['name', 'home.pillows.color'], + filterableColumns: { 'home.pillows.color': [FilterOperator.EQ] }, +} + +const result = await paginate(query, catRepo, config) +``` + ## Usage of pagination type You can use either `limit`/`offset` or `take`/`skip` to return paginated results. diff --git a/src/filter.ts b/src/filter.ts index d656571..a24acdf 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -104,8 +104,14 @@ export function addWhereCondition(qb: SelectQueryBuilder, column: string, const isEmbedded = checkIsEmbedded(qb, columnProperties.propertyPath) const alias = fixColumnAlias(columnProperties, qb.alias, isRelation, isVirtualProperty, isEmbedded, virtualQuery) filter[column].forEach((columnFilter: Filter, index: number) => { - const columnNamePerIteration = `${column}${index}` - const condition = generatePredicateCondition(qb, column, columnFilter, alias, isVirtualProperty) + const columnNamePerIteration = `${columnProperties.column}${index}` + const condition = generatePredicateCondition( + qb, + columnProperties.column, + columnFilter, + alias, + isVirtualProperty + ) const parameters = fixQueryParam(alias, columnNamePerIteration, columnFilter, condition, { [columnNamePerIteration]: columnFilter.findOperator.value, }) diff --git a/src/helper.ts b/src/helper.ts index 6695c0a..8abbf0c 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -1,7 +1,11 @@ import { SelectQueryBuilder } from 'typeorm' import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata' -type Join = K extends string ? (P extends string ? `${K}${'' extends P ? '' : '.'}${P}` : never) : never +type Join = K extends string + ? P extends string + ? `${K}${'' extends P ? '' : '.'}${P | `(${P}` | `${P})`}` + : never + : never type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...0[]] @@ -33,16 +37,30 @@ export type SortBy = Order[] export const positiveNumberOrDefault = (value: number | undefined, defaultValue: number, minValue: 0 | 1 = 0) => value === undefined || value < minValue ? defaultValue : value -export type ColumnProperties = { propertyPath?: string; propertyName: string } +export type ColumnProperties = { propertyPath?: string; propertyName: string; isEmbedded: boolean; column: string } export function getPropertiesByColumnName(column: string): ColumnProperties { const propertyPath = column.split('.') - return propertyPath.length > 1 - ? { - propertyPath: propertyPath[0], - propertyName: propertyPath.slice(1).join('.'), // the join is in case of an embedded entity - } - : { propertyName: propertyPath[0] } + if (propertyPath.length > 1) { + const propertyNamePath = propertyPath.slice(1) + let isEmbedded = false, + propertyName = propertyNamePath.join('.') + + if (!propertyName.startsWith('(') && propertyNamePath.length > 1) { + isEmbedded = true + } + + propertyName = propertyName.replace('(', '').replace(')', '') + + return { + propertyPath: propertyPath[0], + propertyName, // the join is in case of an embedded entity + isEmbedded, + column: `${propertyPath[0]}.${propertyName}`, + } + } else { + return { propertyName: propertyPath[0], isEmbedded: false, column: propertyPath[0] } + } } export function extractVirtualProperty( @@ -106,7 +124,7 @@ export function fixColumnAlias( if (isRelation) { if (isVirtualProperty && query) { return `(${query(`${alias}_${properties.propertyPath}`)})` // () is needed to avoid parameter conflict - } else if (isVirtualProperty && !query) { + } else if ((isVirtualProperty && !query) || properties.isEmbedded) { return `${alias}_${properties.propertyPath}_${properties.propertyName}` } else { return `${alias}_${properties.propertyPath}.${properties.propertyName}` diff --git a/src/paginate.spec.ts b/src/paginate.spec.ts index 89a2447..de4c273 100644 --- a/src/paginate.spec.ts +++ b/src/paginate.spec.ts @@ -882,7 +882,7 @@ describe('paginate', () => { it('should return result based on sort on embedded entity on one-to-many relation', async () => { const config: PaginateConfig = { - sortableColumns: ['id', 'name', 'toys.size.height', 'toys.size.length', 'toys.size.width'], + sortableColumns: ['id', 'name', 'toys.(size.height)', 'toys.(size.length)', 'toys.(size.width)'], searchableColumns: ['name'], relations: ['toys'], } @@ -890,8 +890,8 @@ describe('paginate', () => { path: '', sortBy: [ ['id', 'DESC'], - ['toys.size.height', 'ASC'], - ['toys.size.length', 'ASC'], + ['toys.(size.height)', 'ASC'], + ['toys.(size.length)', 'ASC'], ], } @@ -918,21 +918,21 @@ describe('paginate', () => { ] expect(result.data).toStrictEqual(orderedCats) expect(result.links.current).toBe( - '?page=1&limit=20&sortBy=id:DESC&sortBy=toys.size.height:ASC&sortBy=toys.size.length:ASC' + '?page=1&limit=20&sortBy=id:DESC&sortBy=toys.(size.height):ASC&sortBy=toys.(size.length):ASC' ) }) it('should return result based on sort on embedded entity on many-to-one relation', async () => { const config: PaginateConfig = { - sortableColumns: ['id', 'name', 'cat.size.height', 'cat.size.length', 'cat.size.width'], + sortableColumns: ['id', 'name', 'cat.(size.height)', 'cat.(size.length)', 'cat.(size.width)'], searchableColumns: ['name'], relations: ['cat'], } const query: PaginateQuery = { path: '', sortBy: [ - ['cat.size.height', 'DESC'], - ['cat.size.length', 'DESC'], + ['cat.(size.height)', 'DESC'], + ['cat.(size.length)', 'DESC'], ['name', 'ASC'], ], } @@ -942,19 +942,19 @@ describe('paginate', () => { expect(result.data).toStrictEqual(orderedToys) expect(result.links.current).toBe( - '?page=1&limit=20&sortBy=cat.size.height:DESC&sortBy=cat.size.length:DESC&sortBy=name:ASC' + '?page=1&limit=20&sortBy=cat.(size.height):DESC&sortBy=cat.(size.length):DESC&sortBy=name:ASC' ) }) it('should return result based on sort on embedded entity on one-to-one relation', async () => { const config: PaginateConfig = { - sortableColumns: ['id', 'name', 'cat.size.height', 'cat.size.length', 'cat.size.width'], + sortableColumns: ['id', 'name', 'cat.(size.height)', 'cat.(size.length)', 'cat.(size.width)'], searchableColumns: ['name'], relations: ['cat'], } const query: PaginateQuery = { path: '', - sortBy: [['cat.size.height', 'DESC']], + sortBy: [['cat.(size.height)', 'DESC']], } const result = await paginate(query, catHomeRepo, config) @@ -964,7 +964,7 @@ describe('paginate', () => { orderedHomes[1].countCat = cats.filter((cat) => cat.id === orderedHomes[1].cat.id).length expect(result.data).toStrictEqual(orderedHomes) - expect(result.links.current).toBe('?page=1&limit=20&sortBy=cat.size.height:DESC') + expect(result.links.current).toBe('?page=1&limit=20&sortBy=cat.(size.height):DESC') }) it('should return result based on search on embedded entity', async () => { @@ -1008,8 +1008,8 @@ describe('paginate', () => { it('should return result based on search term on embedded entity on many-to-one relation', async () => { const config: PaginateConfig = { - sortableColumns: ['id', 'name', 'cat.size.height', 'cat.size.length', 'cat.size.width'], - searchableColumns: ['cat.size.height'], + sortableColumns: ['id', 'name', 'cat.(size.height)', 'cat.(size.length)', 'cat.(size.width)'], + searchableColumns: ['cat.(size.height)'], relations: ['cat'], } const query: PaginateQuery = { @@ -1025,8 +1025,8 @@ describe('paginate', () => { it('should return result based on search term on embedded entity on one-to-many relation', async () => { const config: PaginateConfig = { - sortableColumns: ['id', 'name', 'toys.size.height', 'toys.size.length', 'toys.size.width'], - searchableColumns: ['toys.size.height'], + sortableColumns: ['id', 'name', 'toys.(size.height)', 'toys.(size.length)', 'toys.(size.width)'], + searchableColumns: ['toys.(size.height)'], relations: ['toys'], } const query: PaginateQuery = { @@ -1051,8 +1051,8 @@ describe('paginate', () => { it('should return result based on search term on embedded entity on one-to-one relation', async () => { const config: PaginateConfig = { - sortableColumns: ['id', 'name', 'cat.size.height', 'cat.size.length', 'cat.size.width'], - searchableColumns: ['cat.size.height'], + sortableColumns: ['id', 'name', 'cat.(size.height)', 'cat.(size.length)', 'cat.(size.width)'], + searchableColumns: ['cat.(size.height)'], relations: ['cat'], } const query: PaginateQuery = { @@ -1069,20 +1069,20 @@ describe('paginate', () => { it('should return result based on sort and search on embedded many-to-one relation', async () => { const config: PaginateConfig = { - sortableColumns: ['id', 'name', 'cat.size.height', 'cat.size.length', 'cat.size.width'], - searchableColumns: ['cat.size.width'], + sortableColumns: ['id', 'name', 'cat.(size.height)', 'cat.(size.length)', 'cat.(size.width)'], + searchableColumns: ['cat.(size.width)'], relations: ['cat'], } const query: PaginateQuery = { path: '', search: '1', - sortBy: [['cat.size.height', 'DESC']], + sortBy: [['cat.(size.height)', 'DESC']], } const result = await paginate(query, catToyRepo, config) expect(result.meta.search).toStrictEqual('1') expect(result.data).toStrictEqual([catToys[3], catToys[0], catToys[1], catToys[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') }) it('should return result based on filter on embedded entity', async () => { @@ -1143,23 +1143,23 @@ describe('paginate', () => { relations: ['cat'], sortableColumns: ['id', 'name'], filterableColumns: { - 'cat.size.height': [FilterSuffix.NOT], + 'cat.(size.height)': [FilterSuffix.NOT], }, } const query: PaginateQuery = { path: '', filter: { - 'cat.size.height': '$not:25', + 'cat.(size.height)': '$not:25', }, } const result = await paginate(query, catToyRepo, config) expect(result.meta.filter).toStrictEqual({ - 'cat.size.height': '$not:25', + 'cat.(size.height)': '$not:25', }) expect(result.data).toStrictEqual([catToys[3]]) - expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.size.height=$not:25') + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.(size.height)=$not:25') }) it('should return result based on filter on embedded on one-to-many relation', async () => { @@ -1167,13 +1167,13 @@ describe('paginate', () => { relations: ['toys'], sortableColumns: ['id', 'name'], filterableColumns: { - 'toys.size.height': [FilterOperator.EQ], + 'toys.(size.height)': [FilterOperator.EQ], }, } const query: PaginateQuery = { path: '', filter: { - 'toys.size.height': '1', + 'toys.(size.height)': '1', }, } @@ -1185,10 +1185,10 @@ describe('paginate', () => { cat2.toys = [catToys3] expect(result.meta.filter).toStrictEqual({ - 'toys.size.height': '1', + 'toys.(size.height)': '1', }) expect(result.data).toStrictEqual([cat2]) - expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.toys.size.height=1') + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.toys.(size.height)=1') }) it('should return result based on filter on embedded on one-to-one relation', async () => { @@ -1196,25 +1196,25 @@ describe('paginate', () => { relations: ['cat'], sortableColumns: ['id', 'name'], filterableColumns: { - 'cat.size.height': [FilterOperator.EQ], + 'cat.(size.height)': [FilterOperator.EQ], }, } const query: PaginateQuery = { path: '', filter: { - 'cat.size.height': '$eq:30', + 'cat.(size.height)': '$eq:30', }, } const result = await paginate(query, catHomeRepo, config) expect(result.meta.filter).toStrictEqual({ - 'cat.size.height': '$eq:30', + 'cat.(size.height)': '$eq:30', }) const catClone = clone(catHomes[1]) catClone.countCat = cats.filter((cat) => cat.size.height === 30 && cat.id == catClone.cat.id).length expect(result.data).toStrictEqual([catClone]) - expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.size.height=$eq:30') + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.(size.height)=$eq:30') }) it('should return result based on $in filter on embedded on one-to-one relation', async () => { @@ -1222,20 +1222,20 @@ describe('paginate', () => { relations: ['cat'], sortableColumns: ['id', 'name'], filterableColumns: { - 'cat.size.height': [FilterOperator.IN], + 'cat.(size.height)': [FilterOperator.IN], }, } const query: PaginateQuery = { path: '', filter: { - 'cat.size.height': '$in:10,30,35', + 'cat.(size.height)': '$in:10,30,35', }, } const result = await paginate(query, catHomeRepo, config) expect(result.meta.filter).toStrictEqual({ - 'cat.size.height': '$in:10,30,35', + 'cat.(size.height)': '$in:10,30,35', }) const catClone = clone(catHomes[1]) catClone.countCat = cats.filter( @@ -1244,7 +1244,7 @@ describe('paginate', () => { cat.id == catClone.cat.id ).length expect(result.data).toStrictEqual([catClone]) - expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.size.height=$in:10,30,35') + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.(size.height)=$in:10,30,35') }) it('should return result based on $btw filter on embedded on one-to-one relation', async () => { @@ -1252,20 +1252,20 @@ describe('paginate', () => { relations: ['cat'], sortableColumns: ['id', 'name'], filterableColumns: { - 'cat.size.height': [FilterOperator.BTW], + 'cat.(size.height)': [FilterOperator.BTW], }, } const query: PaginateQuery = { path: '', filter: { - 'cat.size.height': '$btw:18,33', + 'cat.(size.height)': '$btw:18,33', }, } const result = await paginate(query, catHomeRepo, config) expect(result.meta.filter).toStrictEqual({ - 'cat.size.height': '$btw:18,33', + 'cat.(size.height)': '$btw:18,33', }) const catHomeClone = clone(catHomes) @@ -1276,7 +1276,7 @@ describe('paginate', () => { (cat) => cat.size.height >= 18 && cat.size.height <= 33 && cat.id == catHomeClone[1].cat.id ).length expect(result.data).toStrictEqual([catHomeClone[0], catHomeClone[1]]) - expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.size.height=$btw:18,33') + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.(size.height)=$btw:18,33') }) it('should return result based on where array and filter', async () => { @@ -1954,12 +1954,12 @@ describe('paginate', () => { it('should return selected columns', async () => { const config: PaginateConfig = { sortableColumns: ['id', 'name'], - select: ['id', 'name', 'toys.name', 'toys.size.height', 'toys.size.length'], + select: ['id', 'name', 'toys.name', 'toys.(size.height)', 'toys.(size.length)'], relations: ['toys'], } const query: PaginateQuery = { path: '', - select: ['id', 'toys.size.height'], + select: ['id', 'toys.(size.height)'], } const result = await paginate(query, catRepo, config) @@ -2015,4 +2015,62 @@ describe('paginate', () => { expect(result.data[0].toys).toHaveLength(1) }) + + it('should search nested relations', async () => { + const config: PaginateConfig = { + relations: { home: { pillows: true } }, + sortableColumns: ['id', 'name'], + searchableColumns: ['name', 'home.pillows.color'], + } + const query: PaginateQuery = { + path: '', + search: 'pink', + } + + const result = await paginate(query, catRepo, config) + + const cat = clone(cats[1]) + const catHomesClone = clone(catHomes[1]) + const catHomePillowsClone = clone(catHomePillows[3]) + delete catHomePillowsClone.home + + catHomesClone.countCat = cats.filter((cat) => cat.id === catHomesClone.cat.id).length + catHomesClone.pillows = [catHomePillowsClone] + cat.home = catHomesClone + delete cat.home.cat + + expect(result.meta.search).toStrictEqual('pink') + expect(result.data).toStrictEqual([cat]) + expect(result.data[0].home).toBeDefined() + expect(result.data[0].home.pillows).toStrictEqual(cat.home.pillows) + }) + + it('should filter nested relations', async () => { + const config: PaginateConfig = { + relations: { home: { pillows: true } }, + sortableColumns: ['id', 'name'], + filterableColumns: { 'home.pillows.color': [FilterOperator.EQ] }, + } + const query: PaginateQuery = { + path: '', + filter: { 'home.pillows.color': 'pink' }, + } + + const result = await paginate(query, catRepo, config) + + const cat = clone(cats[1]) + const catHomesClone = clone(catHomes[1]) + const catHomePillowsClone = clone(catHomePillows[3]) + delete catHomePillowsClone.home + + catHomesClone.countCat = cats.filter((cat) => cat.id === catHomesClone.cat.id).length + catHomesClone.pillows = [catHomePillowsClone] + cat.home = catHomesClone + delete cat.home.cat + + expect(result.meta.filter['home.pillows.color']).toStrictEqual('pink') + expect(result.data).toStrictEqual([cat]) + expect(result.data[0].home).toBeDefined() + expect(result.data[0].home.pillows).toStrictEqual(cat.home.pillows) + }) }) diff --git a/src/paginate.ts b/src/paginate.ts index cb31701..2f3565e 100644 --- a/src/paginate.ts +++ b/src/paginate.ts @@ -62,13 +62,13 @@ export interface PaginateConfig { sortableColumns: Column[] nullSort?: 'first' | 'last' searchableColumns?: Column[] - select?: Column[] + select?: Column[] | string[] maxLimit?: number defaultSortBy?: SortBy defaultLimit?: number where?: FindOptionsWhere | FindOptionsWhere[] filterableColumns?: { - [key in Column]?: (FilterOperator | FilterSuffix)[] + [key in Column | string]?: (FilterOperator | FilterSuffix)[] } loadEagerRelations?: boolean withDeleted?: boolean @@ -264,9 +264,10 @@ export async function paginate( isEmbeded, virtualQuery ) + const condition: WherePredicateOperator = { operator: 'ilike', - parameters: [alias, `:${column}`], + parameters: [alias, `:${property.column}`], } if (['postgres', 'cockroachdb'].includes(queryBuilder.connection.options.type)) { @@ -274,7 +275,7 @@ export async function paginate( } qb.orWhere(qb['createWhereConditionExpression'](condition), { - [column]: `%${query.search}%`, + [property.column]: `%${query.search}%`, }) } })