Skip to content

Commit fd8c36a

Browse files
authored
feat: add wildcard support for select parameters (#1083)
- Add support for '*' wildcard to select all columns from main entity - Add support for 'relation.*' wildcard to select all columns from relation entities - Implement wildcard expansion for both config.select and query.select - Handle embedded entity columns in wildcard expansion - Preserve column order and remove duplicates in expanded selections
1 parent 090f0b2 commit fd8c36a

File tree

3 files changed

+299
-9
lines changed

3 files changed

+299
-9
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,16 @@ const paginateConfig: PaginateConfig<CatEntity> {
251251
* Description: TypeORM partial selection. Limit selection further by using `select` query param.
252252
* https://typeorm.io/select-query-builder#partial-selection
253253
* Note: if you do not contain the primary key in the select array, primary key will be added automatically.
254+
*
255+
* Wildcard support:
256+
* - Use '*' to select all columns from the main entity.
257+
* - Use 'relation.*' to select all columns from a relation.
258+
* - Use 'relation.subrelation.*' to select all columns from nested relations.
259+
*
260+
* Examples:
261+
* select: ['*'] - Selects all columns from main entity
262+
* select: ['id', 'name', 'toys.*'] - Selects id, name from main entity and all columns from toys relation
263+
* select: ['*', 'toys.*'] - Selects all columns from both main entity and toys relation
254264
*/
255265
select: ['id', 'name', 'color'],
256266

src/paginate.spec.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4635,4 +4635,194 @@ describe('paginate', () => {
46354635
})
46364636
})
46374637
})
4638+
4639+
describe('Wildcard Select', () => {
4640+
it('should expand * wildcard to all main entity columns', async () => {
4641+
const query: PaginateQuery = {
4642+
page: 1,
4643+
limit: 10,
4644+
select: ['*'],
4645+
path: '/cats',
4646+
}
4647+
4648+
const result = await paginate(query, catRepo, {
4649+
sortableColumns: ['id'],
4650+
select: ['*'],
4651+
})
4652+
4653+
expect(result.data[0]).toHaveProperty('id')
4654+
expect(result.data[0]).toHaveProperty('name')
4655+
expect(result.data[0]).toHaveProperty('color')
4656+
expect(result.data[0]).toHaveProperty('age')
4657+
expect(result.data[0]).toHaveProperty('cutenessLevel')
4658+
expect(result.data[0]).toHaveProperty('lastVetVisit')
4659+
expect(result.data[0]).toHaveProperty('createdAt')
4660+
expect(result.data[0]).toHaveProperty('deletedAt')
4661+
expect(result.data[0]).toHaveProperty('weightChange')
4662+
expect(result.data[0]).toHaveProperty('size')
4663+
expect(result.data[0]).toHaveProperty('size.height')
4664+
expect(result.data[0]).toHaveProperty('size.width')
4665+
expect(result.data[0]).toHaveProperty('size.length')
4666+
})
4667+
4668+
it('should expand relation.* wildcard to all relation columns', async () => {
4669+
const query: PaginateQuery = {
4670+
page: 1,
4671+
limit: 10,
4672+
select: ['id', 'name', 'toys.*'],
4673+
path: '/cats',
4674+
}
4675+
4676+
const result = await paginate(query, catRepo, {
4677+
sortableColumns: ['id'],
4678+
select: ['id', 'name', 'toys.*'],
4679+
relations: ['toys'],
4680+
})
4681+
4682+
expect(result.data[0]).toHaveProperty('id')
4683+
expect(result.data[0]).toHaveProperty('name')
4684+
expect(result.data[0]).not.toHaveProperty('color')
4685+
expect(result.data[0]).not.toHaveProperty('age')
4686+
expect(result.data[0]).not.toHaveProperty('cutenessLevel')
4687+
expect(result.data[0]).not.toHaveProperty('lastVetVisit')
4688+
expect(result.data[0]).not.toHaveProperty('createdAt')
4689+
expect(result.data[0]).not.toHaveProperty('deletedAt')
4690+
expect(result.data[0]).not.toHaveProperty('weightChange')
4691+
expect(result.data[0].toys[0]).toHaveProperty('id')
4692+
expect(result.data[0].toys[0]).toHaveProperty('name')
4693+
expect(result.data[0].toys[0]).toHaveProperty('createdAt')
4694+
expect(result.data[0].toys[0]).toHaveProperty('size')
4695+
expect(result.data[0].toys[0]).toHaveProperty('size.height')
4696+
expect(result.data[0].toys[0]).toHaveProperty('size.width')
4697+
expect(result.data[0].toys[0]).toHaveProperty('size.length')
4698+
})
4699+
4700+
it('should handle both * and relation.* wildcards together', async () => {
4701+
const query: PaginateQuery = {
4702+
page: 1,
4703+
limit: 10,
4704+
select: ['*', 'toys.*'],
4705+
path: '/cats',
4706+
}
4707+
4708+
const result = await paginate(query, catRepo, {
4709+
sortableColumns: ['id'],
4710+
select: ['*', 'toys.*'],
4711+
relations: ['toys'],
4712+
})
4713+
4714+
expect(result.data[0]).toHaveProperty('id')
4715+
expect(result.data[0]).toHaveProperty('name')
4716+
expect(result.data[0]).toHaveProperty('color')
4717+
expect(result.data[0]).toHaveProperty('age')
4718+
expect(result.data[0]).toHaveProperty('cutenessLevel')
4719+
expect(result.data[0]).toHaveProperty('lastVetVisit')
4720+
expect(result.data[0]).toHaveProperty('createdAt')
4721+
expect(result.data[0]).toHaveProperty('deletedAt')
4722+
expect(result.data[0]).toHaveProperty('weightChange')
4723+
expect(result.data[0].toys[0]).toHaveProperty('id')
4724+
expect(result.data[0].toys[0]).toHaveProperty('name')
4725+
expect(result.data[0].toys[0]).toHaveProperty('createdAt')
4726+
})
4727+
4728+
it('should handle non-existent relation wildcard gracefully', async () => {
4729+
const query: PaginateQuery = {
4730+
page: 1,
4731+
limit: 10,
4732+
select: ['id', 'name', 'nonExistentRelation.*'],
4733+
path: '/cats',
4734+
}
4735+
4736+
const result = await paginate(query, catRepo, {
4737+
sortableColumns: ['id'],
4738+
select: ['id', 'name', 'nonExistentRelation.*'],
4739+
})
4740+
4741+
expect(result.data[0]).toHaveProperty('id')
4742+
expect(result.data[0]).toHaveProperty('name')
4743+
expect(result.data[0]).not.toHaveProperty('nonExistentRelation.*')
4744+
})
4745+
4746+
it('should handle nested relation wildcards correctly', async () => {
4747+
const query: PaginateQuery = {
4748+
page: 1,
4749+
limit: 10,
4750+
select: ['*', 'toys.*', 'toys.shop.*', 'toys.shop.address.*'],
4751+
sortBy: [
4752+
['id', 'ASC'],
4753+
['toys.id', 'ASC'],
4754+
],
4755+
path: '/cats',
4756+
}
4757+
4758+
const result = await paginate(query, catRepo, {
4759+
sortableColumns: ['id', 'toys.id'],
4760+
select: ['*', 'toys.*', 'toys.shop.*', 'toys.shop.address.*'],
4761+
relations: ['toys', 'toys.shop', 'toys.shop.address'],
4762+
})
4763+
4764+
expect(result.data[0]).toHaveProperty('id')
4765+
expect(result.data[0]).toHaveProperty('name')
4766+
expect(result.data[0].toys[1]).toHaveProperty('id')
4767+
expect(result.data[0].toys[1]).toHaveProperty('name')
4768+
expect(result.data[0].toys[1].shop).toHaveProperty('id')
4769+
expect(result.data[0].toys[1].shop).toHaveProperty('shopName')
4770+
expect(result.data[0].toys[1].shop.address).toHaveProperty('id')
4771+
expect(result.data[0].toys[1].shop.address).toHaveProperty('address')
4772+
})
4773+
4774+
it('should restrict query.select to only fields allowed in config.select', async () => {
4775+
// Server-side config only allows id and name
4776+
const config: PaginateConfig<CatEntity> = {
4777+
sortableColumns: ['id'],
4778+
select: ['id', 'name'],
4779+
}
4780+
4781+
// Client tries to request additional fields
4782+
const query: PaginateQuery = {
4783+
page: 1,
4784+
limit: 10,
4785+
select: ['id', 'name', 'color', 'age'], // color and age not in config.select
4786+
path: '/cats',
4787+
}
4788+
4789+
const result = await paginate(query, catRepo, config)
4790+
4791+
// Should only include fields that exist in both config.select and query.select
4792+
expect(result.data[0]).toHaveProperty('id')
4793+
expect(result.data[0]).toHaveProperty('name')
4794+
expect(result.data[0]).not.toHaveProperty('color')
4795+
expect(result.data[0]).not.toHaveProperty('age')
4796+
})
4797+
4798+
it('should restrict wildcard query.select to only fields allowed in config.select', async () => {
4799+
// Server-side config only allows id, name and toys.id
4800+
const config: PaginateConfig<CatEntity> = {
4801+
sortableColumns: ['id'],
4802+
select: ['id', 'name', 'toys.id'],
4803+
relations: ['toys'],
4804+
}
4805+
4806+
// Client tries to request all fields with wildcards
4807+
const query: PaginateQuery = {
4808+
page: 1,
4809+
limit: 10,
4810+
select: ['*', 'toys.*'], // Requesting all fields with wildcards
4811+
path: '/cats',
4812+
}
4813+
4814+
const result = await paginate(query, catRepo, config)
4815+
4816+
// Should only include fields that exist in both expanded config.select and expanded query.select
4817+
expect(result.data[0]).toHaveProperty('id')
4818+
expect(result.data[0]).toHaveProperty('name')
4819+
expect(result.data[0]).not.toHaveProperty('color')
4820+
expect(result.data[0]).not.toHaveProperty('age')
4821+
4822+
// Should only have toys.id, not other toy properties
4823+
expect(result.data[0].toys[0]).toHaveProperty('id')
4824+
expect(result.data[0].toys[0]).not.toHaveProperty('name')
4825+
expect(result.data[0].toys[0]).not.toHaveProperty('createdAt')
4826+
})
4827+
})
46384828
})

src/paginate.ts

Lines changed: 99 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { Logger, ServiceUnavailableException } from '@nestjs/common'
22
import { mapKeys } from 'lodash'
33
import { stringify } from 'querystring'
4-
import { Brackets, FindOptionsUtils, FindOptionsWhere, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'
4+
import {
5+
Brackets,
6+
EntityMetadata,
7+
FindOptionsUtils,
8+
FindOptionsWhere,
9+
ObjectLiteral,
10+
Repository,
11+
SelectQueryBuilder,
12+
} from 'typeorm'
513
import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause'
614
import { PaginateQuery } from './decorator'
715
import { addFilter, FilterOperator, FilterSuffix } from './filter'
@@ -697,14 +705,96 @@ export async function paginate<T extends ObjectLiteral>(
697705
}
698706
}
699707

700-
// When we partial select the columns (main or relation) we must add the primary key column otherwise
701-
// typeorm will not be able to map the result.
702-
// so, we check if the selected columns are a subset of the primary key columns
703-
// and add the missing ones.
704-
const selectParams =
705-
config.select && query.select && !config.ignoreSelectInQueryParam
706-
? config.select.filter((column) => query.select.includes(column))
707-
: config.select
708+
/**
709+
* Expands select parameters containing wildcards (*) into actual column lists
710+
*
711+
* @returns Array of expanded column names
712+
*/
713+
const expandWildcardSelect = <T>(selectParams: string[], queryBuilder: SelectQueryBuilder<T>): string[] => {
714+
const expandedParams: string[] = []
715+
716+
const mainAlias = queryBuilder.expressionMap.mainAlias
717+
const mainMetadata = mainAlias.metadata
718+
719+
/**
720+
* Internal function to expand wildcards
721+
*
722+
* @returns Array of expanded column names
723+
*/
724+
const _expandWidcard = (entityPath: string, metadata: EntityMetadata): string[] => {
725+
const expanded: string[] = []
726+
727+
// Add all columns from the relation entity
728+
expanded.push(
729+
...metadata.columns
730+
.filter(
731+
(col) =>
732+
!metadata.embeddeds
733+
.map((embedded) => embedded.columns.map((embeddedCol) => embeddedCol.propertyName))
734+
.flat()
735+
.includes(col.propertyName)
736+
)
737+
.map((col) => (entityPath ? `${entityPath}.${col.propertyName}` : col.propertyName))
738+
)
739+
740+
// Add columns from embedded entities in the relation
741+
metadata.embeddeds.forEach((embedded) => {
742+
expanded.push(
743+
...embedded.columns.map((col) => `${entityPath}.(${embedded.propertyName}.${col.propertyName})`)
744+
)
745+
})
746+
747+
return expanded
748+
}
749+
750+
for (const param of selectParams) {
751+
if (param === '*') {
752+
expandedParams.push(..._expandWidcard('', mainMetadata))
753+
} else if (param.endsWith('.*')) {
754+
// Handle relation entity wildcards (e.g. 'user.*', 'user.profile.*')
755+
const parts = param.slice(0, -2).split('.')
756+
let currentPath = ''
757+
let currentMetadata = mainMetadata
758+
759+
for (let i = 0; i < parts.length; i++) {
760+
const part = parts[i]
761+
currentPath = currentPath ? `${currentPath}.${part}` : part
762+
const relation = currentMetadata.findRelationWithPropertyPath(part)
763+
764+
if (relation) {
765+
currentMetadata = relation.inverseEntityMetadata
766+
if (i === parts.length - 1) {
767+
// Expand wildcard at the last part
768+
expandedParams.push(..._expandWidcard(currentPath, currentMetadata))
769+
}
770+
} else {
771+
break
772+
}
773+
}
774+
} else {
775+
// Add regular columns as is
776+
expandedParams.push(param)
777+
}
778+
}
779+
780+
// Remove duplicates while preserving order
781+
return [...new Set(expandedParams)]
782+
}
783+
784+
const selectParams = (() => {
785+
// Expand wildcards in config.select if it exists
786+
const expandedConfigSelect = config.select ? expandWildcardSelect(config.select, queryBuilder) : undefined
787+
788+
// Expand wildcards in query.select if it exists
789+
const expandedQuerySelect = query.select ? expandWildcardSelect(query.select, queryBuilder) : undefined
790+
791+
// Filter config.select with expanded query.select if both exist and ignoreSelectInQueryParam is false
792+
if (expandedConfigSelect && expandedQuerySelect && !config.ignoreSelectInQueryParam) {
793+
return expandedConfigSelect.filter((column) => expandedQuerySelect.includes(column))
794+
}
795+
796+
return expandedConfigSelect
797+
})()
708798

709799
if (selectParams?.length > 0) {
710800
let cols: string[] = selectParams.reduce((cols, currentCol) => {

0 commit comments

Comments
 (0)