Skip to content

Commit

Permalink
feat: basic json column support (#1007)
Browse files Browse the repository at this point in the history
  • Loading branch information
orecus authored Oct 24, 2024
1 parent 6869a78 commit 858be33
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 9 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,12 @@ const config: PaginateConfig<CatEntity> = {

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

## Jsonb Filters

You can filter on jsonb columns by using the dot notation. Json columns is limited to `$eq` operators only.

`?filter.metadata.enabled=$eq:true` where column `metadata` is jsonb and contains an object with the key `enabled`.

## Multi Filters

Multi filters are filters that can be applied to a single column with a comparator.
Expand Down
9 changes: 8 additions & 1 deletion src/__tests__/cat-hair.entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'
import { Column, CreateDateColumn, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'

@Entity()
export class CatHairEntity {
Expand All @@ -13,4 +13,11 @@ export class CatHairEntity {

@CreateDateColumn()
createdAt: string

@Column({ type: 'json', nullable: true })
metadata: Record<string, any>

@OneToOne(() => CatHairEntity, (catFur) => catFur.underCoat, { nullable: true })
@JoinColumn()
underCoat: CatHairEntity
}
32 changes: 28 additions & 4 deletions src/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ILike,
In,
IsNull,
JsonContains,
LessThan,
LessThanOrEqual,
MoreThan,
Expand All @@ -20,6 +21,7 @@ import { PaginateQuery } from './decorator'
import {
checkIsArray,
checkIsEmbedded,
checkIsJsonb,
checkIsRelation,
extractVirtualProperty,
fixColumnAlias,
Expand Down Expand Up @@ -238,7 +240,8 @@ export function parseFilterToken(raw?: string): FilterToken | null {

export function parseFilter(
query: PaginateQuery,
filterableColumns?: { [column: string]: (FilterOperator | FilterSuffix)[] | true }
filterableColumns?: { [column: string]: (FilterOperator | FilterSuffix)[] | true },
qb?: SelectQueryBuilder<unknown>
): ColumnsFilters {
const filter: ColumnsFilters = {}
if (!filterableColumns || !query.filter) {
Expand Down Expand Up @@ -284,6 +287,9 @@ export function parseFilter(
const fixValue = (value: string) =>
isISODate(value) ? new Date(value) : Number.isNaN(Number(value)) ? value : Number(value)

const columnProperties = getPropertiesByColumnName(column)
const isJsonb = checkIsJsonb(qb, columnProperties.column)

switch (token.operator) {
case FilterOperator.BTW:
params.findOperator = OperatorSymbolToFunction.get(token.operator)(
Expand All @@ -304,7 +310,26 @@ export function parseFilter(
params.findOperator = OperatorSymbolToFunction.get(token.operator)(fixValue(token.value))
}

filter[column] = [...(filter[column] || []), params]
if (isJsonb) {
const parts = column.split('.')
const dbColumnName = parts[parts.length - 2]
const jsonColumnName = parts[parts.length - 1]

const jsonParams = {
comparator: params.comparator,
findOperator: JsonContains({
[jsonColumnName]: fixValue(token.value),
//! Below seems to not be possible from my understanding, https://github.com/typeorm/typeorm/pull/9665
//! This limits the functionaltiy to $eq only for json columns, which is a bit of a shame.
//! If this is fixed or changed, we can use the commented line below instead.
//[jsonColumnName]: params.findOperator,
}),
}

filter[dbColumnName] = [...(filter[column] || []), jsonParams]
} else {
filter[column] = [...(filter[column] || []), params]
}

if (token.suffix) {
const lastFilterElement = filter[column].length - 1
Expand All @@ -314,7 +339,6 @@ export function parseFilter(
}
}
}

return filter
}

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

const filterEntries = Object.entries(filter)
const orFilters = filterEntries.filter(([_, value]) => value[0].comparator === '$or')
Expand Down
15 changes: 15 additions & 0 deletions src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,21 @@ export function checkIsArray(qb: SelectQueryBuilder<unknown>, propertyName: stri
return !!qb?.expressionMap?.mainAlias?.metadata.findColumnWithPropertyName(propertyName)?.isArray
}

export function checkIsJsonb(qb: SelectQueryBuilder<unknown>, propertyName: string): boolean {
if (!qb || !propertyName) {
return false
}

if (propertyName.includes('.')) {
const parts = propertyName.split('.')
const dbColumnName = parts[parts.length - 2]

return qb?.expressionMap?.mainAlias?.metadata.findColumnWithPropertyName(dbColumnName)?.type === 'json'
}

return qb?.expressionMap?.mainAlias?.metadata.findColumnWithPropertyName(propertyName)?.type === 'json'
}

// This function is used to fix the column alias when using relation, embedded or virtual properties
export function fixColumnAlias(
properties: ColumnProperties,
Expand Down
113 changes: 109 additions & 4 deletions src/paginate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('paginate', () => {
let catHomes: CatHomeEntity[]
let catHomePillows: CatHomePillowEntity[]
let catHairs: CatHairEntity[] = []
let underCoats: CatHairEntity[] = []

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

catHairs = []
underCoats = []

if (process.env.DB === 'postgres') {
catHairRepo = dataSource.getRepository(CatHairEntity)
catHairs = await catHairRepo.save([
catHairRepo.create({ name: 'short', colors: ['white', 'brown', 'black'] }),
catHairRepo.create({ name: 'long', colors: ['white', 'brown'] }),
catHairRepo.create({ name: 'buzzed', colors: ['white'] }),
catHairRepo.create({
name: 'short',
colors: ['white', 'brown', 'black'],
metadata: { length: 5, thickness: 1 },
}),
catHairRepo.create({
name: 'long',
colors: ['white', 'brown'],
metadata: { length: 20, thickness: 5 },
}),
catHairRepo.create({
name: 'buzzed',
colors: ['white'],
metadata: { length: 0.5, thickness: 10 },
}),
catHairRepo.create({ name: 'none' }),
])
}
Expand Down Expand Up @@ -3023,7 +3037,6 @@ describe('paginate', () => {
}

const result = await paginate<CatHairEntity>(query, catHairRepo, config)

expect(result.meta.filter).toStrictEqual({
colors: queryFilter,
})
Expand Down Expand Up @@ -3051,6 +3064,98 @@ describe('paginate', () => {
})
}

if (process.env.DB === 'postgres') {
describe('should be able to filter on jsonb columns', () => {
beforeAll(async () => {
underCoats = await catHairRepo.save([
catHairRepo.create({
name: 'full',
colors: ['orange'],
metadata: { length: 50, thickness: 2 },
underCoat: catHairs[0],
}),
])
})

it('should filter with single value', async () => {
const config: PaginateConfig<CatHairEntity> = {
sortableColumns: ['id'],
filterableColumns: {
'metadata.length': true,
},
}
const query: PaginateQuery = {
path: '',
filter: {
'metadata.length': '$eq:5',
},
}

const result = await paginate<CatHairEntity>(query, catHairRepo, config)

expect(result.meta.filter).toStrictEqual({
'metadata.length': '$eq:5',
})
expect(result.data).toStrictEqual([catHairs[0]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.metadata.length=$eq:5')
})

it('should filter with multiple values', async () => {
const config: PaginateConfig<CatHairEntity> = {
sortableColumns: ['id'],
filterableColumns: {
'metadata.length': true,
'metadata.thickness': true,
},
}
const query: PaginateQuery = {
path: '',
filter: {
'metadata.length': '$eq:0.5',
'metadata.thickness': '$eq:10',
},
}

const result = await paginate<CatHairEntity>(query, catHairRepo, config)

expect(result.meta.filter).toStrictEqual({
'metadata.length': '$eq:0.5',
'metadata.thickness': '$eq:10',
})
expect(result.data).toStrictEqual([catHairs[2]])
expect(result.links.current).toBe(
'?page=1&limit=20&sortBy=id:ASC&filter.metadata.length=$eq:0.5&filter.metadata.thickness=$eq:10'
)
})

it('should filter on a nested property through a relation', async () => {
const config: PaginateConfig<CatHairEntity> = {
sortableColumns: ['id'],
filterableColumns: {
'underCoat.metadata.length': true,
},
relations: ['underCoat'],
}
const query: PaginateQuery = {
path: '',
filter: {
'underCoat.metadata.length': '$eq:50',
},
}

const result = await paginate<CatHairEntity>(query, catHairRepo, config)

expect(result.meta.filter).toStrictEqual({
'underCoat.metadata.length': '$eq:50',
})
expect(result.data).toStrictEqual([underCoats[0]])
expect(result.links.current).toBe(
'?page=1&limit=20&sortBy=id:ASC&filter.underCoat.metadata.length=$eq:50'
)
})
})
}

if (process.env.DB !== 'postgres') {
describe('should return result based on virtual column', () => {
it('should return result sorted and filter by a virtual column in main entity', async () => {
Expand Down

0 comments on commit 858be33

Please sign in to comment.