Skip to content

Commit 858be33

Browse files
authored
feat: basic json column support (#1007)
1 parent 6869a78 commit 858be33

File tree

5 files changed

+166
-9
lines changed

5 files changed

+166
-9
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,12 @@ const config: PaginateConfig<CatEntity> = {
459459

460460
`?filter.roles=$contains:moderator,admin` where column `roles` is an array and contains the values `moderator` and `admin`
461461

462+
## Jsonb Filters
463+
464+
You can filter on jsonb columns by using the dot notation. Json columns is limited to `$eq` operators only.
465+
466+
`?filter.metadata.enabled=$eq:true` where column `metadata` is jsonb and contains an object with the key `enabled`.
467+
462468
## Multi Filters
463469

464470
Multi filters are filters that can be applied to a single column with a comparator.

src/__tests__/cat-hair.entity.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'
1+
import { Column, CreateDateColumn, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'
22

33
@Entity()
44
export class CatHairEntity {
@@ -13,4 +13,11 @@ export class CatHairEntity {
1313

1414
@CreateDateColumn()
1515
createdAt: string
16+
17+
@Column({ type: 'json', nullable: true })
18+
metadata: Record<string, any>
19+
20+
@OneToOne(() => CatHairEntity, (catFur) => catFur.underCoat, { nullable: true })
21+
@JoinColumn()
22+
underCoat: CatHairEntity
1623
}

src/filter.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ILike,
99
In,
1010
IsNull,
11+
JsonContains,
1112
LessThan,
1213
LessThanOrEqual,
1314
MoreThan,
@@ -20,6 +21,7 @@ import { PaginateQuery } from './decorator'
2021
import {
2122
checkIsArray,
2223
checkIsEmbedded,
24+
checkIsJsonb,
2325
checkIsRelation,
2426
extractVirtualProperty,
2527
fixColumnAlias,
@@ -238,7 +240,8 @@ export function parseFilterToken(raw?: string): FilterToken | null {
238240

239241
export function parseFilter(
240242
query: PaginateQuery,
241-
filterableColumns?: { [column: string]: (FilterOperator | FilterSuffix)[] | true }
243+
filterableColumns?: { [column: string]: (FilterOperator | FilterSuffix)[] | true },
244+
qb?: SelectQueryBuilder<unknown>
242245
): ColumnsFilters {
243246
const filter: ColumnsFilters = {}
244247
if (!filterableColumns || !query.filter) {
@@ -284,6 +287,9 @@ export function parseFilter(
284287
const fixValue = (value: string) =>
285288
isISODate(value) ? new Date(value) : Number.isNaN(Number(value)) ? value : Number(value)
286289

290+
const columnProperties = getPropertiesByColumnName(column)
291+
const isJsonb = checkIsJsonb(qb, columnProperties.column)
292+
287293
switch (token.operator) {
288294
case FilterOperator.BTW:
289295
params.findOperator = OperatorSymbolToFunction.get(token.operator)(
@@ -304,7 +310,26 @@ export function parseFilter(
304310
params.findOperator = OperatorSymbolToFunction.get(token.operator)(fixValue(token.value))
305311
}
306312

307-
filter[column] = [...(filter[column] || []), params]
313+
if (isJsonb) {
314+
const parts = column.split('.')
315+
const dbColumnName = parts[parts.length - 2]
316+
const jsonColumnName = parts[parts.length - 1]
317+
318+
const jsonParams = {
319+
comparator: params.comparator,
320+
findOperator: JsonContains({
321+
[jsonColumnName]: fixValue(token.value),
322+
//! Below seems to not be possible from my understanding, https://github.com/typeorm/typeorm/pull/9665
323+
//! This limits the functionaltiy to $eq only for json columns, which is a bit of a shame.
324+
//! If this is fixed or changed, we can use the commented line below instead.
325+
//[jsonColumnName]: params.findOperator,
326+
}),
327+
}
328+
329+
filter[dbColumnName] = [...(filter[column] || []), jsonParams]
330+
} else {
331+
filter[column] = [...(filter[column] || []), params]
332+
}
308333

309334
if (token.suffix) {
310335
const lastFilterElement = filter[column].length - 1
@@ -314,7 +339,6 @@ export function parseFilter(
314339
}
315340
}
316341
}
317-
318342
return filter
319343
}
320344

@@ -323,7 +347,7 @@ export function addFilter<T>(
323347
query: PaginateQuery,
324348
filterableColumns?: { [column: string]: (FilterOperator | FilterSuffix)[] | true }
325349
): SelectQueryBuilder<T> {
326-
const filter = parseFilter(query, filterableColumns)
350+
const filter = parseFilter(query, filterableColumns, qb)
327351

328352
const filterEntries = Object.entries(filter)
329353
const orFilters = filterEntries.filter(([_, value]) => value[0].comparator === '$or')

src/helper.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,21 @@ export function checkIsArray(qb: SelectQueryBuilder<unknown>, propertyName: stri
169169
return !!qb?.expressionMap?.mainAlias?.metadata.findColumnWithPropertyName(propertyName)?.isArray
170170
}
171171

172+
export function checkIsJsonb(qb: SelectQueryBuilder<unknown>, propertyName: string): boolean {
173+
if (!qb || !propertyName) {
174+
return false
175+
}
176+
177+
if (propertyName.includes('.')) {
178+
const parts = propertyName.split('.')
179+
const dbColumnName = parts[parts.length - 2]
180+
181+
return qb?.expressionMap?.mainAlias?.metadata.findColumnWithPropertyName(dbColumnName)?.type === 'json'
182+
}
183+
184+
return qb?.expressionMap?.mainAlias?.metadata.findColumnWithPropertyName(propertyName)?.type === 'json'
185+
}
186+
172187
// This function is used to fix the column alias when using relation, embedded or virtual properties
173188
export function fixColumnAlias(
174189
properties: ColumnProperties,

src/paginate.spec.ts

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ describe('paginate', () => {
4141
let catHomes: CatHomeEntity[]
4242
let catHomePillows: CatHomePillowEntity[]
4343
let catHairs: CatHairEntity[] = []
44+
let underCoats: CatHairEntity[] = []
4445

4546
beforeAll(async () => {
4647
const dbOptions: Omit<Partial<BaseDataSourceOptions>, 'poolSize'> = {
@@ -193,13 +194,26 @@ describe('paginate', () => {
193194
await catRepo.save({ ...cats[0], friends: cats.slice(1) })
194195

195196
catHairs = []
197+
underCoats = []
196198

197199
if (process.env.DB === 'postgres') {
198200
catHairRepo = dataSource.getRepository(CatHairEntity)
199201
catHairs = await catHairRepo.save([
200-
catHairRepo.create({ name: 'short', colors: ['white', 'brown', 'black'] }),
201-
catHairRepo.create({ name: 'long', colors: ['white', 'brown'] }),
202-
catHairRepo.create({ name: 'buzzed', colors: ['white'] }),
202+
catHairRepo.create({
203+
name: 'short',
204+
colors: ['white', 'brown', 'black'],
205+
metadata: { length: 5, thickness: 1 },
206+
}),
207+
catHairRepo.create({
208+
name: 'long',
209+
colors: ['white', 'brown'],
210+
metadata: { length: 20, thickness: 5 },
211+
}),
212+
catHairRepo.create({
213+
name: 'buzzed',
214+
colors: ['white'],
215+
metadata: { length: 0.5, thickness: 10 },
216+
}),
203217
catHairRepo.create({ name: 'none' }),
204218
])
205219
}
@@ -3023,7 +3037,6 @@ describe('paginate', () => {
30233037
}
30243038

30253039
const result = await paginate<CatHairEntity>(query, catHairRepo, config)
3026-
30273040
expect(result.meta.filter).toStrictEqual({
30283041
colors: queryFilter,
30293042
})
@@ -3051,6 +3064,98 @@ describe('paginate', () => {
30513064
})
30523065
}
30533066

3067+
if (process.env.DB === 'postgres') {
3068+
describe('should be able to filter on jsonb columns', () => {
3069+
beforeAll(async () => {
3070+
underCoats = await catHairRepo.save([
3071+
catHairRepo.create({
3072+
name: 'full',
3073+
colors: ['orange'],
3074+
metadata: { length: 50, thickness: 2 },
3075+
underCoat: catHairs[0],
3076+
}),
3077+
])
3078+
})
3079+
3080+
it('should filter with single value', async () => {
3081+
const config: PaginateConfig<CatHairEntity> = {
3082+
sortableColumns: ['id'],
3083+
filterableColumns: {
3084+
'metadata.length': true,
3085+
},
3086+
}
3087+
const query: PaginateQuery = {
3088+
path: '',
3089+
filter: {
3090+
'metadata.length': '$eq:5',
3091+
},
3092+
}
3093+
3094+
const result = await paginate<CatHairEntity>(query, catHairRepo, config)
3095+
3096+
expect(result.meta.filter).toStrictEqual({
3097+
'metadata.length': '$eq:5',
3098+
})
3099+
expect(result.data).toStrictEqual([catHairs[0]])
3100+
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.metadata.length=$eq:5')
3101+
})
3102+
3103+
it('should filter with multiple values', async () => {
3104+
const config: PaginateConfig<CatHairEntity> = {
3105+
sortableColumns: ['id'],
3106+
filterableColumns: {
3107+
'metadata.length': true,
3108+
'metadata.thickness': true,
3109+
},
3110+
}
3111+
const query: PaginateQuery = {
3112+
path: '',
3113+
filter: {
3114+
'metadata.length': '$eq:0.5',
3115+
'metadata.thickness': '$eq:10',
3116+
},
3117+
}
3118+
3119+
const result = await paginate<CatHairEntity>(query, catHairRepo, config)
3120+
3121+
expect(result.meta.filter).toStrictEqual({
3122+
'metadata.length': '$eq:0.5',
3123+
'metadata.thickness': '$eq:10',
3124+
})
3125+
expect(result.data).toStrictEqual([catHairs[2]])
3126+
expect(result.links.current).toBe(
3127+
'?page=1&limit=20&sortBy=id:ASC&filter.metadata.length=$eq:0.5&filter.metadata.thickness=$eq:10'
3128+
)
3129+
})
3130+
3131+
it('should filter on a nested property through a relation', async () => {
3132+
const config: PaginateConfig<CatHairEntity> = {
3133+
sortableColumns: ['id'],
3134+
filterableColumns: {
3135+
'underCoat.metadata.length': true,
3136+
},
3137+
relations: ['underCoat'],
3138+
}
3139+
const query: PaginateQuery = {
3140+
path: '',
3141+
filter: {
3142+
'underCoat.metadata.length': '$eq:50',
3143+
},
3144+
}
3145+
3146+
const result = await paginate<CatHairEntity>(query, catHairRepo, config)
3147+
3148+
expect(result.meta.filter).toStrictEqual({
3149+
'underCoat.metadata.length': '$eq:50',
3150+
})
3151+
expect(result.data).toStrictEqual([underCoats[0]])
3152+
expect(result.links.current).toBe(
3153+
'?page=1&limit=20&sortBy=id:ASC&filter.underCoat.metadata.length=$eq:50'
3154+
)
3155+
})
3156+
})
3157+
}
3158+
30543159
if (process.env.DB !== 'postgres') {
30553160
describe('should return result based on virtual column', () => {
30563161
it('should return result sorted and filter by a virtual column in main entity', async () => {

0 commit comments

Comments
 (0)