From ee86e8bdf6dd0ec8ac75ba921639998fa8dd9611 Mon Sep 17 00:00:00 2001 From: Jonathan Chapman Date: Mon, 4 Dec 2023 02:32:05 -0700 Subject: [PATCH] feat: added operators for postgres array column type (#826) --- .gitignore | 1 + jest.setup.ts | 3 ++ package-lock.json | 16 ++++--- package.json | 10 ++-- src/__tests__/cat-hair.entity.ts | 16 +++++++ src/filter.ts | 6 +++ src/helper.ts | 7 +++ src/paginate.spec.ts | 82 ++++++++++++++++++++++++++++++-- 8 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 jest.setup.ts create mode 100644 src/__tests__/cat-hair.entity.ts diff --git a/.gitignore b/.gitignore index 1bc9909..4699cc5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ coverage/ npm-debug.log .history .idea/ +.env diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..13d92c8 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,3 @@ +import * as dotenv from 'dotenv' + +dotenv.config({ path: '.env' }) diff --git a/package-lock.json b/package-lock.json index e6d4897..b55685c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@types/node": "^20.10.1", "@typescript-eslint/eslint-plugin": "^6.11.0", "@typescript-eslint/parser": "^6.11.0", + "dotenv": "^16.3.1", "eslint": "^8.54.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.1", @@ -3484,12 +3485,15 @@ } }, "node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", "dev": true, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, "node_modules/ee-first": { @@ -10950,9 +10954,9 @@ } }, "dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", "dev": true }, "ee-first": { diff --git a/package.json b/package.json index a78a888..5d882aa 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "files": [ "lib/**/*" ], - "description": "Pagination and filtering helper method for TypeORM repostiories or query builders using Nest.js framework.", + "description": "Pagination and filtering helper method for TypeORM repositories or query builders using Nest.js framework.", "keywords": [ "nestjs", "typeorm", @@ -32,15 +32,16 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand" }, "devDependencies": { - "@nestjs/testing": "^10.2.10", - "@nestjs/platform-express": "^10.2.10", "@nestjs/common": "^10.2.10", + "@nestjs/platform-express": "^10.2.10", + "@nestjs/testing": "^10.2.10", "@types/express": "^4.17.21", "@types/jest": "^29.5.10", "@types/lodash": "^4.14.201", "@types/node": "^20.10.1", "@typescript-eslint/eslint-plugin": "^6.11.0", "@typescript-eslint/parser": "^6.11.0", + "dotenv": "^16.3.1", "eslint": "^8.54.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.1", @@ -78,7 +79,8 @@ "^.+\\.(t|j)s$": "ts-jest" }, "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "setupFiles": ["/../jest.setup.ts"] }, "repository": { "type": "git", diff --git a/src/__tests__/cat-hair.entity.ts b/src/__tests__/cat-hair.entity.ts new file mode 100644 index 0000000..26d3829 --- /dev/null +++ b/src/__tests__/cat-hair.entity.ts @@ -0,0 +1,16 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm' + +@Entity() +export class CatHairEntity { + @PrimaryGeneratedColumn() + id: number + + @Column() + name: string + + @Column({ type: 'text', array: true, default: '{}' }) + colors: string[] + + @CreateDateColumn() + createdAt: string +} diff --git a/src/filter.ts b/src/filter.ts index 0b86449..85f4b9c 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -18,6 +18,7 @@ import { import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause' import { PaginateQuery } from './decorator' import { + checkIsArray, checkIsEmbedded, checkIsRelation, extractVirtualProperty, @@ -162,6 +163,8 @@ export function addWhereCondition(qb: SelectQueryBuilder, column: string, const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(qb, columnProperties) const isRelation = checkIsRelation(qb, columnProperties.propertyPath) const isEmbedded = checkIsEmbedded(qb, columnProperties.propertyPath) + const isArray = checkIsArray(qb, columnProperties.propertyName) + const alias = fixColumnAlias(columnProperties, qb.alias, isRelation, isVirtualProperty, isEmbedded, virtualQuery) filter[column].forEach((columnFilter: Filter, index: number) => { const columnNamePerIteration = `${columnProperties.column}${index}` @@ -175,6 +178,9 @@ export function addWhereCondition(qb: SelectQueryBuilder, column: string, const parameters = fixQueryParam(alias, columnNamePerIteration, columnFilter, condition, { [columnNamePerIteration]: columnFilter.findOperator.value, }) + if (isArray && condition.parameters?.length && !['not', 'isNull'].includes(condition.operator)) { + condition.parameters[0] = `cardinality(${condition.parameters[0]})` + } if (columnFilter.comparator === FilterComparator.OR) { qb.orWhere(qb['createWhereConditionExpression'](condition), parameters) } else { diff --git a/src/helper.ts b/src/helper.ts index e85e929..266ad66 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -126,6 +126,13 @@ export function checkIsEmbedded(qb: SelectQueryBuilder, propertyPath: s return !!qb?.expressionMap?.mainAlias?.metadata?.hasEmbeddedWithPropertyPath(propertyPath) } +export function checkIsArray(qb: SelectQueryBuilder, propertyName: string): boolean { + if (!qb || !propertyName) { + return false + } + return !!qb?.expressionMap?.mainAlias?.metadata.findColumnWithPropertyName(propertyName)?.isArray +} + // This function is used to fix the column alias when using relation, embedded or virtual properties export function fixColumnAlias( properties: ColumnProperties, diff --git a/src/paginate.spec.ts b/src/paginate.spec.ts index 5ef167e..32f88a7 100644 --- a/src/paginate.spec.ts +++ b/src/paginate.spec.ts @@ -18,6 +18,8 @@ import { } from './filter' import { ToyShopEntity } from './__tests__/toy-shop.entity' import { ToyShopAddressEntity } from './__tests__/toy-shop-address.entity' +import * as process from 'process' +import { CatHairEntity } from './__tests__/cat-hair.entity' const isoStringToDate = (isoString) => new Date(isoString) @@ -25,6 +27,7 @@ describe('paginate', () => { let dataSource: DataSource let catRepo: Repository let catToyRepo: Repository + let catHairRepo: Repository let toyShopRepo: Repository let toyShopAddressRepository: Repository let catHomeRepo: Repository @@ -36,17 +39,18 @@ describe('paginate', () => { let toysShops: ToyShopEntity[] let catHomes: CatHomeEntity[] let catHomePillows: CatHomePillowEntity[] + let catHairs: CatHairEntity[] = [] beforeAll(async () => { dataSource = new DataSource({ ...(process.env.DB === 'postgres' ? { type: 'postgres', - host: 'localhost', - port: 5432, - username: 'root', - password: 'pass', - database: 'test', + host: process.env.DB_HOST || 'localhost', + port: +process.env.DB_PORT || 5432, + username: process.env.DB_USERNAME || 'root', + password: process.env.DB_PASSWORD || 'pass', + database: process.env.DB_DATABASE || 'test', } : { type: 'sqlite', @@ -61,6 +65,7 @@ describe('paginate', () => { CatHomeEntity, CatHomePillowEntity, ToyShopEntity, + process.env.DB === 'postgres' ? CatHairEntity : undefined, ], }) await dataSource.initialize() @@ -163,6 +168,18 @@ describe('paginate', () => { // add friends to Milo await catRepo.save({ ...cats[0], friends: cats.slice(1) }) + + catHairs = [] + + 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: 'none' }), + ]) + } }) if (process.env.DB === 'postgres') { @@ -2813,6 +2830,61 @@ describe('paginate', () => { }) }) + if (process.env.DB === 'postgres') { + describe('should return results for an array column', () => { + it.each` + operator | data | expectedIndexes + ${'$not:$null'} | ${undefined} | ${[0, 1, 2, 3]} + ${'$lt'} | ${2} | ${[2, 3]} + ${'$lte'} | ${2} | ${[1, 2, 3]} + ${'$btw'} | ${'1,2'} | ${[1, 2]} + ${'$gte'} | ${2} | ${[0, 1]} + ${'$gt'} | ${2} | ${[0]} + `('with $operator operator', async ({ operator, data, expectedIndexes }) => { + const config: PaginateConfig = { + sortableColumns: ['id'], + filterableColumns: { + colors: true, + }, + } + + const queryFilter = `${operator}${data ? `:${data}` : ''}` + const query: PaginateQuery = { + path: '', + filter: { + colors: queryFilter, + }, + } + + const result = await paginate(query, catHairRepo, config) + + expect(result.meta.filter).toStrictEqual({ + colors: queryFilter, + }) + expect(result.data).toStrictEqual(expectedIndexes.map((index) => catHairs[index])) + expect(result.links.current).toBe(`?page=1&limit=20&sortBy=id:ASC&filter.colors=${queryFilter}`) + }) + + it('should work with search', async () => { + const config: PaginateConfig = { + sortableColumns: ['id'], + searchableColumns: ['colors'], + } + + const query: PaginateQuery = { + path: '', + search: 'brown', + } + + const result = await paginate(query, catHairRepo, config) + + expect(result.meta.search).toStrictEqual('brown') + expect(result.data).toStrictEqual([catHairs[0], catHairs[1]]) + expect(result.links.current).toBe(`?page=1&limit=20&sortBy=id:ASC&search=brown`) + }) + }) + } + 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 () => {