From be99407c56cc26fafb90c9601fa60a7bd47965b7 Mon Sep 17 00:00:00 2001 From: daniel-maegerli Date: Wed, 15 Mar 2023 12:14:47 +0100 Subject: [PATCH 1/8] test case for filtering null and not null on relation --- src/__tests__/cat-home.entity.ts | 3 +++ src/paginate.spec.ts | 38 +++++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/__tests__/cat-home.entity.ts b/src/__tests__/cat-home.entity.ts index 45b52fc1..4e448498 100644 --- a/src/__tests__/cat-home.entity.ts +++ b/src/__tests__/cat-home.entity.ts @@ -10,6 +10,9 @@ export class CatHomeEntity { @Column() name: string + @Column({ nullable: true }) + street: string | null + @OneToOne(() => CatEntity, (cat) => cat.home) cat: CatEntity diff --git a/src/paginate.spec.ts b/src/paginate.spec.ts index ce6dc2b2..f4ef7d64 100644 --- a/src/paginate.spec.ts +++ b/src/paginate.spec.ts @@ -177,8 +177,8 @@ describe('paginate', () => { }) catHomes = await catHomeRepo.save([ - catHomeRepo.create({ name: 'Box', cat: cats[0] }), - catHomeRepo.create({ name: 'House', cat: cats[1] }), + catHomeRepo.create({ name: 'Box', cat: cats[0], street: null }), + catHomeRepo.create({ name: 'House', cat: cats[1], street: 'Mainstreet' }), ]) catHomePillows = await catHomePillowRepo.save([ catHomePillowRepo.create({ color: 'red', home: catHomes[0] }), @@ -2022,26 +2022,52 @@ describe('paginate', () => { const config: PaginateConfig = { sortableColumns: ['id'], filterableColumns: { - 'home.name': [FilterSuffix.NOT, FilterOperator.NULL], + 'home.street': [FilterSuffix.NOT, FilterOperator.NULL], }, relations: ['home'], } const query: PaginateQuery = { path: '', filter: { - 'home.name': '$not:$null', + 'home.street': '$not:$null', }, } const result = await paginate(query, catRepo, config) - const expectedResult = [0, 1].map((i) => { + const expectedResult = [1].map((i) => { const ret = Object.assign(clone(cats[i]), { home: Object.assign(clone(catHomes[i])) }) delete ret.home.cat return ret }) expect(result.data).toStrictEqual(expectedResult) - expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.home.name=$not:$null') + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.home.street=$not:$null') + }) + + it('should return result based on null query on relation', async () => { + const config: PaginateConfig = { + sortableColumns: ['id'], + filterableColumns: { + 'home.street': [FilterOperator.NULL], + }, + relations: ['home'], + } + const query: PaginateQuery = { + path: '', + filter: { + 'home.street': '$null', + }, + } + + const result = await paginate(query, catRepo, config) + const expectedResult = [0].map((i) => { + const ret = Object.assign(clone(cats[i]), { home: Object.assign(clone(catHomes[i])) }) + delete ret.home.cat + return ret + }) + + expect(result.data).toStrictEqual(expectedResult) + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.home.name=$null') }) it('should ignore filterable column which is not configured', async () => { From e509be551b1d56d6fa15723d9c8d1116304e6bd6 Mon Sep 17 00:00:00 2001 From: Robin De Schepper Date: Mon, 30 Sep 2024 13:52:07 -0400 Subject: [PATCH 2/8] added triple nested relationship to generalize nested testing --- src/__tests__/cat-home-pillow-brand.entity.ts | 13 +++++++++++++ src/__tests__/cat-home-pillow.entity.ts | 4 ++++ src/__tests__/cat-home.entity.ts | 14 +++++++++++++- 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/cat-home-pillow-brand.entity.ts diff --git a/src/__tests__/cat-home-pillow-brand.entity.ts b/src/__tests__/cat-home-pillow-brand.entity.ts new file mode 100644 index 00000000..74c56dd1 --- /dev/null +++ b/src/__tests__/cat-home-pillow-brand.entity.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm' + +@Entity() +export class CatHomePillowBrandEntity { + @PrimaryGeneratedColumn() + id: number + + @Column() + name: string + + @Column({ nullable: true }) + quality: string +} diff --git a/src/__tests__/cat-home-pillow.entity.ts b/src/__tests__/cat-home-pillow.entity.ts index ff940805..a5b7dadd 100644 --- a/src/__tests__/cat-home-pillow.entity.ts +++ b/src/__tests__/cat-home-pillow.entity.ts @@ -1,5 +1,6 @@ import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' import { CatHomeEntity } from './cat-home.entity' +import { CatHomePillowBrandEntity } from './cat-home-pillow-brand.entity' @Entity() export class CatHomePillowEntity { @@ -12,6 +13,9 @@ export class CatHomePillowEntity { @Column() color: string + @ManyToOne(() => CatHomePillowBrandEntity) + brand: CatHomePillowBrandEntity + @CreateDateColumn() createdAt: string } diff --git a/src/__tests__/cat-home.entity.ts b/src/__tests__/cat-home.entity.ts index 4e448498..736fe2d5 100644 --- a/src/__tests__/cat-home.entity.ts +++ b/src/__tests__/cat-home.entity.ts @@ -1,4 +1,13 @@ -import { Column, CreateDateColumn, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, VirtualColumn } from 'typeorm' +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + VirtualColumn, +} from 'typeorm' import { CatHomePillowEntity } from './cat-home-pillow.entity' import { CatEntity } from './cat.entity' @@ -19,6 +28,9 @@ export class CatHomeEntity { @OneToMany(() => CatHomePillowEntity, (pillow) => pillow.home) pillows: CatHomePillowEntity[] + @ManyToOne(() => CatHomePillowEntity, { nullable: true }) + naptimePillow: CatHomePillowEntity | null + @CreateDateColumn() createdAt: string From fe174ff73836aaf2732c3acccdaeb953b6bdf559 Mon Sep 17 00:00:00 2001 From: Robin De Schepper Date: Mon, 30 Sep 2024 13:52:29 -0400 Subject: [PATCH 3/8] update spec to triple nested relationship --- src/paginate.spec.ts | 199 +++++++++++++++++++++---------------------- 1 file changed, 98 insertions(+), 101 deletions(-) diff --git a/src/paginate.spec.ts b/src/paginate.spec.ts index f4ef7d64..046f29fe 100644 --- a/src/paginate.spec.ts +++ b/src/paginate.spec.ts @@ -15,12 +15,13 @@ import { FilterComparator, FilterOperator, FilterSuffix, - OperatorSymbolToFunction, isOperator, isSuffix, + OperatorSymbolToFunction, parseFilterToken, } from './filter' -import { PaginateConfig, Paginated, PaginationLimit, paginate } from './paginate' +import { paginate, PaginateConfig, Paginated, PaginationLimit } from './paginate' +import { CatHomePillowBrandEntity } from './__tests__/cat-home-pillow-brand.entity' const isoStringToDate = (isoString) => new Date(isoString) @@ -33,6 +34,7 @@ describe('paginate', () => { let toyShopAddressRepository: Repository let catHomeRepo: Repository let catHomePillowRepo: Repository + let catHomePillowBrandRepo: Repository let cats: CatEntity[] let catToys: CatToyEntity[] let catToysWithoutShop: CatToyEntity[] @@ -40,6 +42,8 @@ describe('paginate', () => { let toysShops: ToyShopEntity[] let catHomes: CatHomeEntity[] let catHomePillows: CatHomePillowEntity[] + let naptimePillow: CatHomePillowEntity + let pillowBrand: CatHomePillowBrandEntity let catHairs: CatHairEntity[] = [] beforeAll(async () => { @@ -53,6 +57,7 @@ describe('paginate', () => { ToyShopAddressEntity, CatHomeEntity, CatHomePillowEntity, + CatHomePillowBrandEntity, ToyShopEntity, process.env.DB === 'postgres' ? CatHairEntity : undefined, ], @@ -96,6 +101,7 @@ describe('paginate', () => { catToyRepo = dataSource.getRepository(CatToyEntity) catHomeRepo = dataSource.getRepository(CatHomeEntity) catHomePillowRepo = dataSource.getRepository(CatHomePillowEntity) + catHomePillowBrandRepo = dataSource.getRepository(CatHomePillowBrandEntity) toyShopRepo = dataSource.getRepository(ToyShopEntity) toyShopAddressRepository = dataSource.getRepository(ToyShopAddressEntity) @@ -176,9 +182,12 @@ describe('paginate', () => { return newInstance }) + pillowBrand = await catHomePillowBrandRepo.save({ name: 'Purrfection', quality: null }) + naptimePillow = await catHomePillowRepo.save({ color: 'black', brand: pillowBrand }) catHomes = await catHomeRepo.save([ - catHomeRepo.create({ name: 'Box', cat: cats[0], street: null }), - catHomeRepo.create({ name: 'House', cat: cats[1], street: 'Mainstreet' }), + catHomeRepo.create({ name: 'Box', cat: cats[0], street: null, naptimePillow: null }), + catHomeRepo.create({ name: 'House', cat: cats[1], street: 'Mainstreet', naptimePillow: null }), + catHomeRepo.create({ name: 'Mansion', cat: cats[2], street: 'Boulevard Avenue', naptimePillow }), ]) catHomePillows = await catHomePillowRepo.save([ catHomePillowRepo.create({ color: 'red', home: catHomes[0] }), @@ -771,7 +780,7 @@ describe('paginate', () => { it('should return result based on search term on one-to-one relation', async () => { const config: PaginateConfig = { - relations: ['cat'], + relations: ['cat', 'naptimePillow.brand'], sortableColumns: ['id', 'name', 'cat.id'], } const query: PaginateQuery = { @@ -782,9 +791,10 @@ describe('paginate', () => { const result = await paginate(query, catHomeRepo, config) expect(result.meta.sortBy).toStrictEqual([['cat.id', 'DESC']]) - const catHomesClone = clone([catHomes[0], catHomes[1]]) + const catHomesClone = clone([catHomes[0], catHomes[1], catHomes[2]]) catHomesClone[0].countCat = cats.filter((cat) => cat.id === catHomesClone[0].cat.id).length catHomesClone[1].countCat = cats.filter((cat) => cat.id === catHomesClone[1].cat.id).length + catHomesClone[2].countCat = cats.filter((cat) => cat.id === catHomesClone[2].cat.id).length expect(result.data).toStrictEqual(catHomesClone.sort((a, b) => b.cat.id - a.cat.id)) expect(result.links.current).toBe('?page=1&limit=20&sortBy=cat.id:DESC') @@ -839,7 +849,7 @@ describe('paginate', () => { it('should return result based on sort on one-to-one relation', async () => { const config: PaginateConfig = { - relations: ['cat'], + relations: ['cat', 'naptimePillow.brand'], sortableColumns: ['id', 'name'], searchableColumns: ['name', 'cat.name'], } @@ -861,7 +871,7 @@ describe('paginate', () => { it('should load nested relations (object notation)', async () => { const config: PaginateConfig = { - relations: { home: { pillows: true } }, + relations: { home: { pillows: true, naptimePillow: { brand: true } } }, sortableColumns: ['id', 'name'], searchableColumns: ['name'], } @@ -894,7 +904,7 @@ describe('paginate', () => { it('should load nested relations (array notation)', async () => { const config: PaginateConfig = { - relations: ['home.pillows'], + relations: ['home.pillows', 'home.naptimePillow.brand'], sortableColumns: ['id', 'name'], searchableColumns: ['name'], } @@ -1232,7 +1242,7 @@ describe('paginate', () => { it('should return result based on filter on one-to-one relation', async () => { const config: PaginateConfig = { - relations: ['cat'], + relations: ['cat', 'naptimePillow.brand'], sortableColumns: ['id', 'name'], filterableColumns: { 'cat.name': [FilterSuffix.NOT], @@ -1251,16 +1261,17 @@ describe('paginate', () => { 'cat.name': '$not:Garfield', }) - const catHomesClone = clone(catHomes[0]) - catHomesClone.countCat = cats.filter((cat) => cat.id === catHomesClone.cat.id).length + const catHomesClones = [clone(catHomes[0]), clone(catHomes[2])] + catHomesClones[0].countCat = cats.filter((cat) => cat.id === catHomesClones[0].cat.id).length + catHomesClones[1].countCat = cats.filter((cat) => cat.id === catHomesClones[1].cat.id).length - expect(result.data).toStrictEqual([catHomesClone]) + expect(result.data).toStrictEqual(catHomesClones) expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.name=$not:Garfield') }) it('should return result based on $in filter on one-to-one relation', async () => { const config: PaginateConfig = { - relations: ['cat'], + relations: ['cat', 'naptimePillow.brand'], sortableColumns: ['id', 'name'], filterableColumns: { 'cat.age': [FilterOperator.IN], @@ -1279,16 +1290,17 @@ describe('paginate', () => { 'cat.age': '$in:4,6', }) - const catHomesClone = clone(catHomes[0]) - catHomesClone.countCat = cats.filter((cat) => cat.id === catHomesClone.cat.id).length + const catHomesClones = [clone(catHomes[0]), clone(catHomes[2])] + catHomesClones[0].countCat = cats.filter((cat) => cat.id === catHomesClones[0].cat.id).length + catHomesClones[1].countCat = cats.filter((cat) => cat.id === catHomesClones[1].cat.id).length - expect(result.data).toStrictEqual([catHomesClone]) + expect(result.data).toStrictEqual(catHomesClones) expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.age=$in:4,6') }) it('should return result based on $btw filter on one-to-one relation', async () => { const config: PaginateConfig = { - relations: ['cat'], + relations: ['cat', 'naptimePillow.brand'], sortableColumns: ['id', 'name'], filterableColumns: { 'cat.age': [FilterOperator.BTW], @@ -1338,7 +1350,7 @@ describe('paginate', () => { const config: PaginateConfig = { sortableColumns: ['id', 'name', 'size.height', 'size.length', 'size.width', 'toys.(size.height)'], searchableColumns: ['name'], - relations: ['home', 'toys'], + relations: ['home', 'toys', 'home.naptimePillow.brand'], } const query: PaginateQuery = { path: '', @@ -1367,6 +1379,7 @@ describe('paginate', () => { copyCats[0].home = copyHomes[0] copyCats[1].home = copyHomes[1] + copyCats[2].home = copyHomes[2] const copyToys = catToysWithoutShop.map((toy: CatToyEntity) => { const copy = clone(toy) @@ -1454,7 +1467,7 @@ describe('paginate', () => { const config: PaginateConfig = { sortableColumns: ['id', 'name', 'cat.(size.height)', 'cat.(size.length)', 'cat.(size.width)'], searchableColumns: ['name'], - relations: ['cat'], + relations: ['cat', 'naptimePillow.brand'], } const query: PaginateQuery = { path: '', @@ -1462,10 +1475,11 @@ describe('paginate', () => { } const result = await paginate(query, catHomeRepo, config) - const orderedHomes = clone([catHomes[1], catHomes[0]]) + const orderedHomes = clone([catHomes[1], catHomes[0], catHomes[2]]) orderedHomes[0].countCat = cats.filter((cat) => cat.id === orderedHomes[0].cat.id).length orderedHomes[1].countCat = cats.filter((cat) => cat.id === orderedHomes[1].cat.id).length + orderedHomes[2].countCat = cats.filter((cat) => cat.id === orderedHomes[2].cat.id).length expect(result.data).toStrictEqual(orderedHomes) expect(result.links.current).toBe('?page=1&limit=20&sortBy=cat.(size.height):DESC') @@ -1557,7 +1571,7 @@ describe('paginate', () => { const config: PaginateConfig = { sortableColumns: ['id', 'name', 'cat.(size.height)', 'cat.(size.length)', 'cat.(size.width)'], searchableColumns: ['cat.(size.height)'], - relations: ['cat'], + relations: ['cat', 'naptimePillow.brand'], } const query: PaginateQuery = { path: '', @@ -1622,7 +1636,7 @@ describe('paginate', () => { filterableColumns: { 'size.height': [FilterSuffix.NOT], }, - relations: ['home'], + relations: ['home', 'home.naptimePillow.brand'], } const query: PaginateQuery = { path: '', @@ -1702,7 +1716,7 @@ describe('paginate', () => { it('should return result based on filter on embedded on one-to-one relation', async () => { const config: PaginateConfig = { - relations: ['cat'], + relations: ['cat', 'naptimePillow.brand'], sortableColumns: ['id', 'name'], filterableColumns: { 'cat.(size.height)': [FilterOperator.EQ], @@ -1728,7 +1742,7 @@ describe('paginate', () => { it('should return result based on $in filter on embedded on one-to-one relation', async () => { const config: PaginateConfig = { - relations: ['cat'], + relations: ['cat', 'naptimePillow.brand'], sortableColumns: ['id', 'name'], filterableColumns: { 'cat.(size.height)': [FilterOperator.IN], @@ -1758,7 +1772,7 @@ describe('paginate', () => { it('should return result based on $btw filter on embedded on one-to-one relation', async () => { const config: PaginateConfig = { - relations: ['cat'], + relations: ['cat', 'naptimePillow.brand'], sortableColumns: ['id', 'name'], filterableColumns: { 'cat.(size.height)': [FilterOperator.BTW], @@ -1777,14 +1791,17 @@ describe('paginate', () => { 'cat.(size.height)': '$btw:18,33', }) - const catHomeClone = clone(catHomes) - catHomeClone[0].countCat = cats.filter( - (cat) => cat.size.height >= 18 && cat.size.height <= 33 && cat.id == catHomeClone[0].cat.id + const catHomeClones = clone(catHomes) + catHomeClones[0].countCat = cats.filter( + (cat) => cat.size.height >= 18 && cat.size.height <= 33 && cat.id == catHomeClones[0].cat.id + ).length + catHomeClones[1].countCat = cats.filter( + (cat) => cat.size.height >= 18 && cat.size.height <= 33 && cat.id == catHomeClones[1].cat.id ).length - catHomeClone[1].countCat = cats.filter( - (cat) => cat.size.height >= 18 && cat.size.height <= 33 && cat.id == catHomeClone[1].cat.id + catHomeClones[2].countCat = cats.filter( + (cat) => cat.size.height >= 18 && cat.size.height <= 33 && cat.id == catHomeClones[1].cat.id ).length - expect(result.data).toStrictEqual([catHomeClone[0], catHomeClone[1]]) + expect(result.data).toStrictEqual(catHomeClones) expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.(size.height)=$btw:18,33') }) @@ -2024,7 +2041,7 @@ describe('paginate', () => { filterableColumns: { 'home.street': [FilterSuffix.NOT, FilterOperator.NULL], }, - relations: ['home'], + relations: ['home', 'home.naptimePillow.brand'], } const query: PaginateQuery = { path: '', @@ -2034,12 +2051,14 @@ describe('paginate', () => { } const result = await paginate(query, catRepo, config) - const expectedResult = [1].map((i) => { - const ret = Object.assign(clone(cats[i]), { home: Object.assign(clone(catHomes[i])) }) + const expectedResult = [1, 2].map((i) => { + console.log(catHomes[i]) + const ret = Object.assign(clone(cats[i]), { home: clone(catHomes[i]) }) + ret.home.countCat = 1 delete ret.home.cat return ret }) - + console.log(result.data) expect(result.data).toStrictEqual(expectedResult) expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.home.street=$not:$null') }) @@ -2062,12 +2081,39 @@ describe('paginate', () => { const result = await paginate(query, catRepo, config) const expectedResult = [0].map((i) => { const ret = Object.assign(clone(cats[i]), { home: Object.assign(clone(catHomes[i])) }) + ret.home.countCat = 1 delete ret.home.cat return ret }) expect(result.data).toStrictEqual(expectedResult) - expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.home.name=$null') + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.home.street=$null') + }) + + it('should return result based on null query on nested relation', async () => { + const config: PaginateConfig = { + sortableColumns: ['id'], + filterableColumns: { + 'home.naptimePillow.brand.name': [FilterOperator.NULL], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + 'home.naptimePillow.brand.name': '$null', + }, + } + + const result = await paginate(query, catRepo, config) + const expectedResult = [2].map((i) => { + const ret = Object.assign(clone(cats[i]), { home: Object.assign(clone(catHomes[i])) }) + ret.home.countCat = 1 + delete ret.home.cat + return ret + }) + + expect(result.data).toStrictEqual(expectedResult) + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.home.naptimePillow.brand.name=$null') }) it('should ignore filterable column which is not configured', async () => { @@ -2531,7 +2577,7 @@ describe('paginate', () => { it('should only select columns via query which are selected in config', async () => { const config: PaginateConfig = { select: ['id', 'home.id', 'home.pillows.id'], - relations: { home: { pillows: true } }, + relations: { home: { pillows: true, naptimePillow: { brand: true } } }, sortableColumns: ['id', 'name'], } const query: PaginateQuery = { @@ -2544,7 +2590,7 @@ describe('paginate', () => { result.data.forEach((cat) => { expect(cat.id).toBeDefined() - if (cat.id === 1 || cat.id === 2) { + if (cat.id === 1 || cat.id === 2 || cat.id == 3) { expect(cat.home.id).toBeDefined() expect(cat.home.name).not.toBeDefined() } else { @@ -2557,8 +2603,8 @@ describe('paginate', () => { it('should return the specified nested relationship columns only', async () => { const config: PaginateConfig = { - select: ['id', 'home.id', 'home.pillows.id'], - relations: { home: { pillows: true } }, + select: ['id', 'home.id', 'home.pillows.id', 'home.naptimePillow.brand'], + relations: { home: { pillows: true, naptimePillow: { brand: true } } }, sortableColumns: ['id', 'name'], } const query: PaginateQuery = { @@ -2571,7 +2617,7 @@ describe('paginate', () => { expect(cat.id).toBeDefined() expect(cat.name).not.toBeDefined() - if (cat.id === 1 || cat.id === 2) { + if (cat.id === 1 || cat.id === 2 || cat.id == 3) { expect(cat.home.id).toBeDefined() expect(cat.home.name).not.toBeDefined() expect(cat.home.countCat).not.toBeDefined() @@ -2627,9 +2673,9 @@ describe('paginate', () => { it('should search nested relations', async () => { const config: PaginateConfig = { - relations: { home: { pillows: true } }, + relations: { home: { pillows: true, naptimePillow: { brand: true } } }, sortableColumns: ['id', 'name'], - searchableColumns: ['name', 'home.pillows.color'], + searchableColumns: ['name', 'home.pillows.color', 'home.naptimePillow.brand'], } const query: PaginateQuery = { path: '', @@ -2656,7 +2702,7 @@ describe('paginate', () => { it('should filter nested relations', async () => { const config: PaginateConfig = { - relations: { home: { pillows: true } }, + relations: { home: { pillows: true, naptimePillow: { brand: true } } }, sortableColumns: ['id', 'name'], filterableColumns: { 'home.pillows.color': [FilterOperator.EQ] }, } @@ -3082,56 +3128,7 @@ describe('paginate', () => { it('should return result sorted and filter by a virtual column in main entity', async () => { const config: PaginateConfig = { sortableColumns: ['countCat'], - relations: ['cat'], - filterableColumns: { - countCat: [FilterOperator.GT], - }, - } - const query: PaginateQuery = { - path: '', - filter: { - countCat: '$gt:0', - }, - sortBy: [['countCat', 'ASC']], - } - - const result = await paginate(query, catHomeRepo, config) - - expect(result.data).toStrictEqual([catHomes[0], catHomes[1]]) - expect(result.links.current).toBe('?page=1&limit=20&sortBy=countCat:ASC&filter.countCat=$gt:0') - }) - - it('should return result based on virtual column filter', async () => { - const config: PaginateConfig = { - sortableColumns: ['id'], - filterableColumns: { - 'home.countCat': [FilterOperator.GT], - }, - relations: ['home'], - } - const query: PaginateQuery = { - path: '', - filter: { - 'home.countCat': '$gt:0', - }, - sortBy: [['id', 'ASC']], - } - - const result = await paginate(query, catRepo, config) - const expectedResult = [0, 1].map((i) => { - const ret = Object.assign(clone(cats[i]), { home: Object.assign(clone(catHomes[i])) }) - delete ret.home.cat - return ret - }) - - expect(result.data).toStrictEqual(expectedResult) - expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.home.countCat=$gt:0') - }) - - it('should return result sorted and filter by a virtual column in main entity', async () => { - const config: PaginateConfig = { - sortableColumns: ['countCat'], - relations: ['cat'], + relations: ['cat', 'naptimePillow.brand'], filterableColumns: { countCat: [FilterOperator.GT], }, @@ -3146,7 +3143,7 @@ describe('paginate', () => { const result = await paginate(query, catHomeRepo, config) - expect(result.data).toStrictEqual([catHomes[0], catHomes[1]]) + expect(result.data).toStrictEqual([catHomes[0], catHomes[1], catHomes[2]]) expect(result.links.current).toBe('?page=1&limit=20&sortBy=countCat:ASC&filter.countCat=$gt:0') }) @@ -3156,7 +3153,7 @@ describe('paginate', () => { filterableColumns: { 'home.countCat': [FilterOperator.GT], }, - relations: ['home'], + relations: ['home', 'home.naptimePillow.brand'], } const query: PaginateQuery = { path: '', @@ -3167,7 +3164,7 @@ describe('paginate', () => { } const result = await paginate(query, catRepo, config) - const expectedResult = [0, 1].map((i) => { + const expectedResult = [0, 1, 2].map((i) => { const ret = Object.assign(clone(cats[i]), { home: Object.assign(clone(catHomes[i])) }) delete ret.home.cat return ret @@ -3180,7 +3177,7 @@ describe('paginate', () => { it('should return result sorted by a virtual column', async () => { const config: PaginateConfig = { sortableColumns: ['home.countCat'], - relations: ['home'], + relations: ['home', 'home.naptimePillow.brand'], } const query: PaginateQuery = { path: '', @@ -3188,9 +3185,9 @@ describe('paginate', () => { } const result = await paginate(query, catRepo, config) - const expectedResult = [2, 3, 4, 0, 1].map((i) => { + const expectedResult = [3, 4, 0, 1, 2].map((i) => { const ret = clone(cats[i]) - if (i == 0 || i == 1) { + if (i < 3) { ret.home = clone(catHomes[i]) ret.home.countCat = cats.filter((cat) => cat.id === ret.home.cat.id).length delete ret.home.cat From 06fd26ebec0d487d8e69746b904ef5cde071a12f Mon Sep 17 00:00:00 2001 From: Robin De Schepper Date: Mon, 30 Sep 2024 13:53:05 -0400 Subject: [PATCH 4/8] added optional `joinMethods` configuration --- src/helper.ts | 4 ++++ src/paginate.ts | 13 +++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/helper.ts b/src/helper.ts index 34fdccbd..086c6f10 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -80,6 +80,10 @@ export type RelationColumn = Extract< export type Order = [Column, 'ASC' | 'DESC'] export type SortBy = Order[] +// eslint-disable-next-line @typescript-eslint/ban-types +export type MappedColumns = { [key in Column | (string & {})]: S } +export type JoinMethod = 'leftJoinAndSelect' | 'innerJoinAndSelect' + export function isEntityKey(entityColumns: Column[], column: string): column is Column { return !!entityColumns.find((c) => c === column) } diff --git a/src/paginate.ts b/src/paginate.ts index 1307db5b..75fd7b64 100644 --- a/src/paginate.ts +++ b/src/paginate.ts @@ -27,6 +27,8 @@ import { isEntityKey, isFindOperator, isRepository, + JoinMethod, + MappedColumns, Order, positiveNumberOrDefault, RelationColumn, @@ -66,23 +68,20 @@ export enum PaginationType { TAKE_AND_SKIP = 'take', } +// We use (string & {}) to maintain autocomplete while allowing any string +// see https://github.com/microsoft/TypeScript/issues/29729 export interface PaginateConfig { relations?: FindOptionsRelations | RelationColumn[] | FindOptionsRelationByString sortableColumns: Column[] nullSort?: 'first' | 'last' searchableColumns?: Column[] - // see https://github.com/microsoft/TypeScript/issues/29729 for (string & {}) // eslint-disable-next-line @typescript-eslint/ban-types select?: (Column | (string & {}))[] maxLimit?: number defaultSortBy?: SortBy defaultLimit?: number where?: FindOptionsWhere | FindOptionsWhere[] - filterableColumns?: { - // see https://github.com/microsoft/TypeScript/issues/29729 for (string & {}) - // eslint-disable-next-line @typescript-eslint/ban-types - [key in Column | (string & {})]?: (FilterOperator | FilterSuffix)[] | true - } + filterableColumns?: Partial> loadEagerRelations?: boolean withDeleted?: boolean paginationType?: PaginationType @@ -90,6 +89,8 @@ export interface PaginateConfig { origin?: string ignoreSearchByInQueryParam?: boolean ignoreSelectInQueryParam?: boolean + defaultJoinMethod?: JoinMethod + joinMethods?: Partial> } export enum PaginationLimit { From 80101c3964d91e4e5a19829447a32da8a815ce31 Mon Sep 17 00:00:00 2001 From: Robin De Schepper Date: Mon, 30 Sep 2024 13:55:16 -0400 Subject: [PATCH 5/8] document new config var --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 72e00b56..38de31d1 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,14 @@ const paginateConfig: PaginateConfig { * Description: Prevent `select` query param from limiting selection further. Partial selection will depend upon `select` config option only */ ignoreSelectInQueryParam: true, + + /** + * Required: false + * Type: MappedColumns + * Default: false + * Description: Prevent `select` query param from limiting selection further. Partial selection will depend upon `select` config option only + */ + joinMethods: {age: 'innerJoinAndSelect', size: 'leftJoinAndSelect'} } ``` From 9dd259ec90deb020ebc6f3eccf53042b0ce162d3 Mon Sep 17 00:00:00 2001 From: Robin De Schepper Date: Mon, 30 Sep 2024 14:37:25 -0400 Subject: [PATCH 6/8] added `checkIsNestedRelation` --- src/helper.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/helper.ts b/src/helper.ts index 086c6f10..84c524bf 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -159,6 +159,18 @@ export function checkIsRelation(qb: SelectQueryBuilder, propertyPath: s return !!qb?.expressionMap?.mainAlias?.metadata?.hasRelationWithPropertyPath(propertyPath) } +export function checkIsNestedRelation(qb: SelectQueryBuilder, propertyPath: string): boolean { + let metadata = qb?.expressionMap?.mainAlias?.metadata + for (const relationName of propertyPath.split('.')) { + const relation = metadata?.relations.find((relation) => relation.propertyPath === relationName) + if (!relation) { + return false + } + metadata = relation.inverseEntityMetadata + } + return true +} + export function checkIsEmbedded(qb: SelectQueryBuilder, propertyPath: string): boolean { if (!qb || !propertyPath) { return false From cedab98b433813d8a32e19aca74c662d11228508 Mon Sep 17 00:00:00 2001 From: Robin De Schepper Date: Mon, 30 Sep 2024 14:38:32 -0400 Subject: [PATCH 7/8] return which join methods to use from `addFilter` --- src/filter.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/filter.ts b/src/filter.ts index 6dd60123..cd4cdfaa 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -20,11 +20,13 @@ import { PaginateQuery } from './decorator' import { checkIsArray, checkIsEmbedded, + checkIsNestedRelation, checkIsRelation, extractVirtualProperty, fixColumnAlias, getPropertiesByColumnName, isISODate, + JoinMethod, } from './helper' export enum FilterOperator { @@ -81,7 +83,8 @@ export const OperatorSymbolToFunction = new Map< ]) type Filter = { comparator: FilterComparator; findOperator: FindOperator } -type ColumnsFilters = { [columnName: string]: Filter[] } +type ColumnFilters = { [columnName: string]: Filter[] } +type ColumnJoinMethods = { [columnName: string]: JoinMethod } export interface FilterToken { comparator: FilterComparator @@ -239,8 +242,8 @@ export function parseFilterToken(raw?: string): FilterToken | null { export function parseFilter( query: PaginateQuery, filterableColumns?: { [column: string]: (FilterOperator | FilterSuffix)[] | true } -): ColumnsFilters { - const filter: ColumnsFilters = {} +): ColumnFilters { + const filter: ColumnFilters = {} if (!filterableColumns || !query.filter) { return {} } @@ -322,7 +325,7 @@ export function addFilter( qb: SelectQueryBuilder, query: PaginateQuery, filterableColumns?: { [column: string]: (FilterOperator | FilterSuffix)[] | true } -): SelectQueryBuilder { +): ColumnJoinMethods { const filter = parseFilter(query, filterableColumns) const filterEntries = Object.entries(filter) @@ -345,5 +348,17 @@ export function addFilter( ) } - return qb + // Set the join type of every relationship used in a filter to `innerJoinAndSelect` + // so that records without that relationships don't show up in filters on their columns. + return Object.fromEntries( + filterEntries + .map(([key]) => [key, getPropertiesByColumnName(key)] as const) + .filter(([, properties]) => properties.propertyPath) + .flatMap(([, properties]) => { + const nesting = properties.column.split('.') + return Array.from({ length: nesting.length - 1 }, (_, i) => nesting.slice(0, i + 1).join('.')) + .filter((relation) => checkIsNestedRelation(qb, relation)) + .map((relation) => [relation, 'innerJoinAndSelect'] as const) + }) + ) } From bd3674acbe89a6c2b02fe517d5cf0b51fa5acd2b Mon Sep 17 00:00:00 2001 From: Robin De Schepper Date: Fri, 4 Oct 2024 18:14:18 +0200 Subject: [PATCH 8/8] refactored, cleaned up, and documented new features --- README.md | 10 ++++- src/filter.ts | 2 +- src/helper.ts | 24 ++++++++++- src/paginate.spec.ts | 13 +++--- src/paginate.ts | 99 ++++++++++++++++++++++++++------------------ 5 files changed, 98 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 38de31d1..971aa330 100644 --- a/README.md +++ b/README.md @@ -297,11 +297,19 @@ const paginateConfig: PaginateConfig { */ ignoreSelectInQueryParam: true, + /** + * Required: false + * Type: 'leftJoinAndSelect' | 'innerJoinAndSelect' + * Default: 'leftJoinAndSelect' + * Description: Relationships will be joined with either LEFT JOIN or INNER JOIN, and their columns selected. Can be specified per column with `joinMethods` configuration. + */ + defaultJoinMethod: 'leftJoinAndSelect' + /** * Required: false * Type: MappedColumns * Default: false - * Description: Prevent `select` query param from limiting selection further. Partial selection will depend upon `select` config option only + * Description: Overrides the join method per relationship. */ joinMethods: {age: 'innerJoinAndSelect', size: 'leftJoinAndSelect'} } diff --git a/src/filter.ts b/src/filter.ts index cd4cdfaa..21b306e0 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -161,7 +161,7 @@ export function generatePredicateCondition( ) as WherePredicateOperator } -export function addWhereCondition(qb: SelectQueryBuilder, column: string, filter: ColumnsFilters) { +export function addWhereCondition(qb: SelectQueryBuilder, column: string, filter: ColumnFilters) { const columnProperties = getPropertiesByColumnName(column) const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(qb, columnProperties) const isRelation = checkIsRelation(qb, columnProperties.propertyPath) diff --git a/src/helper.ts b/src/helper.ts index 84c524bf..5c9f0728 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -1,5 +1,13 @@ -import { FindOperator, Repository, SelectQueryBuilder } from 'typeorm' +import { + FindOperator, + FindOptionsRelationByString, + FindOptionsRelations, + Repository, + SelectQueryBuilder, +} from 'typeorm' import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata' +import { OrmUtils } from 'typeorm/util/OrmUtils' +import { mergeWith } from 'lodash' /** * Joins 2 keys as `K`, `K.P`, `K.(P` or `K.P)` @@ -83,6 +91,9 @@ export type SortBy = Order[] // eslint-disable-next-line @typescript-eslint/ban-types export type MappedColumns = { [key in Column | (string & {})]: S } export type JoinMethod = 'leftJoinAndSelect' | 'innerJoinAndSelect' +export type RelationSchemaInput = FindOptionsRelations | RelationColumn[] | FindOptionsRelationByString +// eslint-disable-next-line @typescript-eslint/ban-types +export type RelationSchema = { [relation in Column | (string & {})]: true } export function isEntityKey(entityColumns: Column[], column: string): column is Column { return !!entityColumns.find((c) => c === column) @@ -263,3 +274,14 @@ export function isFindOperator(value: unknown | FindOperator): value is Fi return false } } + +export function createRelationSchema(configurationRelations: RelationSchemaInput): RelationSchema { + return Array.isArray(configurationRelations) + ? OrmUtils.propertyPathsToTruthyObject(configurationRelations) + : configurationRelations +} + +export function mergeRelationSchema(...schemas: RelationSchema[]) { + const noTrueOverride = (obj, source) => (source === true && obj !== undefined ? obj : undefined) + return mergeWith({}, ...schemas, noTrueOverride) +} diff --git a/src/paginate.spec.ts b/src/paginate.spec.ts index 046f29fe..2ab4b276 100644 --- a/src/paginate.spec.ts +++ b/src/paginate.spec.ts @@ -2052,13 +2052,12 @@ describe('paginate', () => { const result = await paginate(query, catRepo, config) const expectedResult = [1, 2].map((i) => { - console.log(catHomes[i]) const ret = Object.assign(clone(cats[i]), { home: clone(catHomes[i]) }) ret.home.countCat = 1 delete ret.home.cat return ret }) - console.log(result.data) + expect(result.data).toStrictEqual(expectedResult) expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.home.street=$not:$null') }) @@ -2069,7 +2068,7 @@ describe('paginate', () => { filterableColumns: { 'home.street': [FilterOperator.NULL], }, - relations: ['home'], + relations: ['home', 'home.naptimePillow.brand'], } const query: PaginateQuery = { path: '', @@ -2094,13 +2093,13 @@ describe('paginate', () => { const config: PaginateConfig = { sortableColumns: ['id'], filterableColumns: { - 'home.naptimePillow.brand.name': [FilterOperator.NULL], + 'home.naptimePillow.brand.quality': [FilterOperator.NULL], }, } const query: PaginateQuery = { path: '', filter: { - 'home.naptimePillow.brand.name': '$null', + 'home.naptimePillow.brand.quality': '$null', }, } @@ -2113,7 +2112,9 @@ describe('paginate', () => { }) expect(result.data).toStrictEqual(expectedResult) - expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.home.naptimePillow.brand.name=$null') + expect(result.links.current).toBe( + '?page=1&limit=20&sortBy=id:ASC&filter.home.naptimePillow.brand.quality=$null' + ) }) it('should ignore filterable column which is not configured', async () => { diff --git a/src/paginate.ts b/src/paginate.ts index 75fd7b64..7d5cf54e 100644 --- a/src/paginate.ts +++ b/src/paginate.ts @@ -1,24 +1,15 @@ import { Logger, ServiceUnavailableException } from '@nestjs/common' import { mapKeys } from 'lodash' import { stringify } from 'querystring' -import { - Brackets, - FindOptionsRelationByString, - FindOptionsRelations, - FindOptionsUtils, - FindOptionsWhere, - ObjectLiteral, - Repository, - SelectQueryBuilder, -} from 'typeorm' +import { Brackets, FindOptionsUtils, FindOptionsWhere, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm' import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause' -import { OrmUtils } from 'typeorm/util/OrmUtils' import { PaginateQuery } from './decorator' import { addFilter, FilterOperator, FilterSuffix } from './filter' import { checkIsEmbedded, checkIsRelation, Column, + createRelationSchema, extractVirtualProperty, fixColumnAlias, getPropertiesByColumnName, @@ -29,9 +20,11 @@ import { isRepository, JoinMethod, MappedColumns, + mergeRelationSchema, Order, positiveNumberOrDefault, - RelationColumn, + RelationSchema, + RelationSchemaInput, SortBy, } from './helper' @@ -71,7 +64,7 @@ export enum PaginationType { // We use (string & {}) to maintain autocomplete while allowing any string // see https://github.com/microsoft/TypeScript/issues/29729 export interface PaginateConfig { - relations?: FindOptionsRelations | RelationColumn[] | FindOptionsRelationByString + relations?: RelationSchemaInput sortableColumns: Column[] nullSort?: 'first' | 'last' searchableColumns?: Column[] @@ -230,30 +223,20 @@ export async function paginate( } } - if (config.relations) { - const relations = Array.isArray(config.relations) - ? OrmUtils.propertyPathsToTruthyObject(config.relations) - : config.relations - const createQueryBuilderRelations = ( - prefix: string, - relations: FindOptionsRelations | RelationColumn[], - alias?: string - ) => { - Object.keys(relations).forEach((relationName) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const relationSchema = relations![relationName]! - - queryBuilder.leftJoinAndSelect( - `${alias ?? prefix}.${relationName}`, - `${alias ?? prefix}_${relationName}_rel` - ) - - if (typeof relationSchema === 'object') { - createQueryBuilderRelations(relationName, relationSchema, `${alias ?? prefix}_${relationName}_rel`) - } - }) - } - createQueryBuilderRelations(queryBuilder.alias, relations) + let filterJoinMethods = {} + if (query.filter) { + filterJoinMethods = addFilter(queryBuilder, query, config.filterableColumns) + } + const joinMethods = { ...filterJoinMethods, ...config.joinMethods } + + // Add the relations specified by the config, or used in the currently + // filtered filterable columns. + if (config.relations || Object.keys(filterJoinMethods).length) { + const relationsSchema = mergeRelationSchema( + createRelationSchema(config.relations), + createRelationSchema(Object.keys(joinMethods)) + ) + addRelationsFromSchema(queryBuilder, relationsSchema, config, joinMethods) } const dbType = (isRepository(repo) ? repo.manager : repo).connection.options.type @@ -384,10 +367,6 @@ export async function paginate( ) } - if (query.filter) { - addFilter(queryBuilder, query, config.filterableColumns) - } - if (query.limit === PaginationLimit.COUNTER_ONLY) { totalItems = await queryBuilder.getCount() } else if (isPaginated) { @@ -464,3 +443,41 @@ export async function paginate( return Object.assign(new Paginated(), results) } + +export function addRelationsFromSchema( + queryBuilder: SelectQueryBuilder, + schema: RelationSchema, + config: PaginateConfig, + joinMethods: Partial> +): void { + const defaultJoinMethod = config.defaultJoinMethod ?? 'leftJoinAndSelect' + + const createQueryBuilderRelations = ( + prefix: string, + relations: RelationSchema, + alias?: string, + parentRelation?: string + ) => { + Object.keys(relations).forEach((relationName) => { + const joinMethod = + joinMethods[parentRelation ? `${parentRelation}.${relationName}` : relationName] ?? defaultJoinMethod + queryBuilder[joinMethod](`${alias ?? prefix}.${relationName}`, `${alias ?? prefix}_${relationName}_rel`) + + // Check whether this is a non-terminal node with a relation schema to load + const relationSchema = relations[relationName] + if ( + typeof relationSchema === 'object' && + relationSchema !== null && + Object.keys(relationSchema).length > 0 + ) { + createQueryBuilderRelations( + relationName, + relationSchema, + `${alias ?? prefix}_${relationName}_rel`, + parentRelation ? `${parentRelation}.${relationName}` : relationName + ) + } + }) + } + createQueryBuilderRelations(queryBuilder.alias, schema) +}