UNPKG

nestjs-paginate

Version:

Pagination and filtering helper method for TypeORM repositories or query builders using Nest.js framework.

1,157 lines 255 kB
"use strict"; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; Object.defineProperty(exports, "__esModule", { value: true }); const common_1 = require("@nestjs/common"); const lodash_1 = require("lodash"); const process = require("process"); const typeorm_1 = require("typeorm"); const cat_hair_entity_1 = require("./__tests__/cat-hair.entity"); const cat_home_pillow_brand_entity_1 = require("./__tests__/cat-home-pillow-brand.entity"); const cat_home_pillow_entity_1 = require("./__tests__/cat-home-pillow.entity"); const cat_home_entity_1 = require("./__tests__/cat-home.entity"); const cat_toy_entity_1 = require("./__tests__/cat-toy.entity"); const cat_entity_1 = require("./__tests__/cat.entity"); const toy_shop_address_entity_1 = require("./__tests__/toy-shop-address.entity"); const toy_shop_entity_1 = require("./__tests__/toy-shop.entity"); const filter_1 = require("./filter"); const paginate_1 = require("./paginate"); const global_config_1 = require("./global-config"); // Disable debug logs during tests beforeAll(() => { jest.spyOn(common_1.Logger.prototype, 'debug').mockImplementation(() => { }); }); afterAll(() => { jest.restoreAllMocks(); // Restore default logger behavior }); const isoStringToDate = (isoString) => new Date(isoString); describe('paginate', () => { let dataSource; let catRepo; let catToyRepo; let catHairRepo; let toyShopRepo; let toyShopAddressRepository; let catHomeRepo; let catHomePillowRepo; let catHomePillowBrandRepo; let cats; let catToys; let catToysWithoutShop; let toyShopsAddresses; let toysShops; let catHomes; let catHomePillows; let naptimePillow; let pillowBrand; let catHairs = []; let underCoats = []; beforeAll(async () => { const dbOptions = { dropSchema: true, synchronize: true, logging: ['error'], entities: [ cat_entity_1.CatEntity, cat_toy_entity_1.CatToyEntity, toy_shop_address_entity_1.ToyShopAddressEntity, cat_home_entity_1.CatHomeEntity, cat_home_pillow_entity_1.CatHomePillowEntity, cat_home_pillow_brand_entity_1.CatHomePillowBrandEntity, toy_shop_entity_1.ToyShopEntity, process.env.DB === 'postgres' ? cat_hair_entity_1.CatHairEntity : undefined, ], }; switch (process.env.DB) { case 'postgres': dataSource = new typeorm_1.DataSource(Object.assign(Object.assign({}, dbOptions), { type: 'postgres', host: process.env.DB_HOST || 'localhost', port: +process.env.POSTGRESS_DB_PORT || 5432, username: process.env.DB_USERNAME || 'root', password: process.env.DB_PASSWORD || 'pass', database: process.env.DB_DATABASE || 'test' })); break; case 'mariadb': dataSource = new typeorm_1.DataSource(Object.assign(Object.assign({}, dbOptions), { type: 'mariadb', host: process.env.DB_HOST || 'localhost', port: +process.env.MARIA_DB_PORT || 3306, username: process.env.DB_USERNAME || 'root', password: process.env.DB_PASSWORD || 'pass', database: process.env.DB_DATABASE || 'test' })); break; case 'sqlite': dataSource = new typeorm_1.DataSource(Object.assign(Object.assign({}, dbOptions), { type: 'sqlite', database: ':memory:' })); break; default: throw new Error('Invalid DB'); } await dataSource.initialize(); catRepo = dataSource.getRepository(cat_entity_1.CatEntity); catToyRepo = dataSource.getRepository(cat_toy_entity_1.CatToyEntity); catHomeRepo = dataSource.getRepository(cat_home_entity_1.CatHomeEntity); catHomePillowRepo = dataSource.getRepository(cat_home_pillow_entity_1.CatHomePillowEntity); catHomePillowBrandRepo = dataSource.getRepository(cat_home_pillow_brand_entity_1.CatHomePillowBrandEntity); toyShopRepo = dataSource.getRepository(toy_shop_entity_1.ToyShopEntity); toyShopAddressRepository = dataSource.getRepository(toy_shop_address_entity_1.ToyShopAddressEntity); cats = await catRepo.save([ catRepo.create({ name: 'Milo', color: 'brown', age: 6, cutenessLevel: cat_entity_1.CutenessLevel.HIGH, lastVetVisit: isoStringToDate('2022-12-19T10:00:00.000Z'), size: { height: 25, width: 10, length: 40 }, weightChange: -0.75, }), catRepo.create({ name: 'Garfield', color: 'ginger', age: 5, cutenessLevel: cat_entity_1.CutenessLevel.MEDIUM, lastVetVisit: isoStringToDate('2022-12-20T10:00:00.000Z'), size: { height: 30, width: 15, length: 45 }, weightChange: 5.25, }), catRepo.create({ name: 'Shadow', color: 'black', age: 4, cutenessLevel: cat_entity_1.CutenessLevel.HIGH, lastVetVisit: isoStringToDate('2022-12-21T10:00:00.000Z'), size: { height: 25, width: 10, length: 50 }, weightChange: -3, }), catRepo.create({ name: 'George', color: 'white', age: 3, cutenessLevel: cat_entity_1.CutenessLevel.LOW, lastVetVisit: null, size: { height: 35, width: 12, length: 40 }, weightChange: 0, }), catRepo.create({ name: 'Leche', color: 'white', age: null, cutenessLevel: cat_entity_1.CutenessLevel.HIGH, lastVetVisit: null, size: { height: 10, width: 5, length: 15 }, weightChange: -1.25, }), catRepo.create({ name: 'Baby', color: 'brown', age: 0, cutenessLevel: cat_entity_1.CutenessLevel.HIGH, lastVetVisit: null, size: { height: 10, width: 5, length: 10 }, weightChange: 0.01, }), catRepo.create({ name: 'Adam', color: 'black', age: 4, cutenessLevel: cat_entity_1.CutenessLevel.LOW, lastVetVisit: isoStringToDate('2022-12-22T10:00:00.000Z'), size: { height: 20, width: 15, length: 50 }, weightChange: 4.75, }), ]); // Link cats via two parallel to-one relations for polymorphic (~) sort tests. // Saved as fresh objects so the shared `cats` fixture is not mutated. Some cats // get a bestFriend, others only a nemesis, so COALESCE(bestFriend.age, nemesis.age) // exercises the fallback from the first source to the second. await catRepo.save([ catRepo.create({ id: cats[0].id, bestFriend: cats[2] }), // Milo -> 4 (Shadow) catRepo.create({ id: cats[1].id, nemesis: cats[0] }), // Garfield -> 6 (Milo) catRepo.create({ id: cats[2].id, bestFriend: cats[3] }), // Shadow -> 3 (George) catRepo.create({ id: cats[3].id, nemesis: cats[5] }), // George -> 0 (Baby) catRepo.create({ id: cats[4].id, bestFriend: cats[1] }), // Leche -> 5 (Garfield) catRepo.create({ id: cats[5].id, nemesis: cats[2] }), // Baby -> 4 (Shadow) catRepo.create({ id: cats[6].id, bestFriend: cats[0] }), // Adam -> 6 (Milo) ]); toyShopsAddresses = await toyShopAddressRepository.save([ toyShopAddressRepository.create({ address: '123 Main St' }), ]); toysShops = await toyShopRepo.save([ toyShopRepo.create({ shopName: 'Best Toys', address: toyShopsAddresses[0] }), toyShopRepo.create({ shopName: 'Lovely Toys' }), ]); catToys = await catToyRepo.save([ catToyRepo.create({ name: 'Fuzzy Thing', cat: cats[0], size: { height: 10, width: 10, length: 10 } }), catToyRepo.create({ name: 'Stuffed Mouse', shop: toysShops[0], cat: cats[0], size: { height: 5, width: 5, length: 12 }, }), catToyRepo.create({ name: 'Mouse', shop: toysShops[1], cat: cats[0], size: { height: 6, width: 4, length: 13 }, }), catToyRepo.create({ name: 'String', cat: cats[1], size: { height: 1, width: 1, length: 50 } }), ]); catToysWithoutShop = catToys.map((_a) => { var { shop: _ } = _a, other = __rest(_a, ["shop"]); const newInstance = new cat_toy_entity_1.CatToyEntity(); for (const otherKey in other) { newInstance[otherKey] = other[otherKey]; } 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, naptimePillow: null, config: { theme: 'dark', fontSize: 14 }, }), catHomeRepo.create({ name: 'House', cat: cats[1], street: 'Mainstreet', naptimePillow: null, config: { theme: 'light', fontSize: 12 }, }), catHomeRepo.create({ name: 'Mansion', cat: cats[2], street: 'Boulevard Avenue', naptimePillow, config: { theme: 'dark', fontSize: 16, nested: { level: 2, tag: 'vip' } }, }), ]); catHomePillows = await catHomePillowRepo.save([ catHomePillowRepo.create({ color: 'red', home: catHomes[0] }), catHomePillowRepo.create({ color: 'yellow', home: catHomes[0] }), catHomePillowRepo.create({ color: 'blue', home: catHomes[0] }), catHomePillowRepo.create({ color: 'pink', home: catHomes[1] }), catHomePillowRepo.create({ color: 'purple', home: catHomes[1] }), catHomePillowRepo.create({ color: 'teal', home: catHomes[1] }), ]); // add friends to Milo await catRepo.save(Object.assign(Object.assign({}, cats[0]), { friends: cats.slice(1) })); catHairs = []; underCoats = []; if (process.env.DB === 'postgres') { catHairRepo = dataSource.getRepository(cat_hair_entity_1.CatHairEntity); catHairs = await catHairRepo.save([ catHairRepo.create({ name: 'short', colors: ['white', 'brown', 'black'], metadata: { length: 5, thickness: 1 }, metadataJson: { length: 5, thickness: 1 }, }), catHairRepo.create({ name: 'long', colors: ['white', 'brown'], metadata: { length: 20, thickness: 5 }, metadataJson: { length: 20, thickness: 5 }, }), catHairRepo.create({ name: 'buzzed', colors: ['white'], metadata: { length: 0.5, thickness: 10 }, metadataJson: { length: 0.5, thickness: 10 }, }), catHairRepo.create({ name: 'none' }), ]); } }); if (process.env.DB === 'postgres') { afterAll(async () => { const entities = dataSource.entityMetadatas; const tableNames = entities.map((entity) => `"${entity.tableName}"`).join(', '); await dataSource.query(`TRUNCATE ${tableNames} RESTART IDENTITY CASCADE;`); }); } it('should return an instance of Paginated', async () => { const config = { sortableColumns: ['id'], defaultSortBy: [['id', 'ASC']], defaultLimit: 1, }; const query = { path: '', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result).toBeInstanceOf(paginate_1.Paginated); expect(result.data).toStrictEqual(cats.slice(0, 1)); }); it('should accept and use empty string as default origin in config, even if global provided', async () => { (0, global_config_1.updateGlobalConfig)({ defaultOrigin: 'http://localhost:8081', }); const config = { sortableColumns: ['id'], defaultSortBy: [['id', 'ASC']], defaultLimit: 1, origin: '', }; const query = { path: 'http://localhost:8080/cat', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result).toBeInstanceOf(paginate_1.Paginated); expect(result.links.current).toStrictEqual('/cat?page=1&limit=1&sortBy=id:ASC'); (0, global_config_1.updateGlobalConfig)({ defaultOrigin: undefined, }); }); it('should use default origin from global config if provided, over the one from request', async () => { (0, global_config_1.updateGlobalConfig)({ defaultOrigin: 'http://localhost:8081', }); const config = { sortableColumns: ['id'], defaultSortBy: [['id', 'ASC']], defaultLimit: 1, }; const query = { path: 'http://localhost:8080/cat', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result).toBeInstanceOf(paginate_1.Paginated); expect(result.links.current).toStrictEqual('http://localhost:8081/cat?page=1&limit=1&sortBy=id:ASC'); (0, global_config_1.updateGlobalConfig)({ defaultOrigin: undefined, }); }); it('should accept a query builder', async () => { const config = { sortableColumns: ['id'], defaultSortBy: [['id', 'ASC']], defaultLimit: 1, }; const query = { path: '', }; const queryBuilder = await catRepo.createQueryBuilder('cats'); const result = await (0, paginate_1.paginate)(query, queryBuilder, config); expect(result.data).toStrictEqual(cats.slice(0, 1)); }); it('should accept a query builder with custom condition', async () => { const config = { sortableColumns: ['id'], defaultSortBy: [['id', 'ASC']], }; const query = { path: '', }; const queryBuilder = await dataSource .createQueryBuilder() .select('cats') .from(cat_entity_1.CatEntity, 'cats') .where('cats.color = :color', { color: 'white' }); const result = await (0, paginate_1.paginate)(query, queryBuilder, config); expect(result.data).toStrictEqual(cats.slice(3, 5)); }); it('should accept query builder and work with query filter', async () => { const config = { sortableColumns: ['id'], defaultSortBy: [['id', 'ASC']], filterableColumns: { 'size.height': true, }, }; const query = { path: '', filter: { 'size.height': '$gte:20', }, }; const queryBuilder = await dataSource .createQueryBuilder() .select('cats') .from(cat_entity_1.CatEntity, 'cats') .where('cats.color = :color', { color: 'white' }); const result = await (0, paginate_1.paginate)(query, queryBuilder, config); expect(result.data).toStrictEqual(cats.slice(3, 4)); }); it('should default to page 1, if negative page is given', async () => { const config = { sortableColumns: ['id'], defaultLimit: 1, }; const query = { path: '', page: -1, }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.meta.currentPage).toBe(1); expect(result.data).toStrictEqual(cats.slice(0, 1)); }); it('should default to limit maxLimit, if maxLimit is not 0', async () => { const config = { sortableColumns: ['id'], maxLimit: 1, defaultLimit: 1, }; const query = { path: '', limit: paginate_1.PaginationLimit.NO_PAGINATION, }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data).toStrictEqual(cats.slice(0, 1)); }); it('should return all cats', async () => { const config = { sortableColumns: ['id'], maxLimit: paginate_1.PaginationLimit.NO_PAGINATION, defaultLimit: 1, }; const query = { path: '', limit: paginate_1.PaginationLimit.NO_PAGINATION, }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data).toStrictEqual(cats); }); it('should limit to query limit, even if maxLimit is set to NO_PAGINATION', async () => { const config = { sortableColumns: ['id'], maxLimit: paginate_1.PaginationLimit.NO_PAGINATION, }; const query = { path: '', limit: 2, }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.meta.itemsPerPage).toBe(2); }); it('should default to limit defaultLimit, if maxLimit is NO_PAGINATION', async () => { const config = { sortableColumns: ['id'], maxLimit: paginate_1.PaginationLimit.NO_PAGINATION, defaultLimit: 1, }; const query = { path: '', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data).toStrictEqual(cats.slice(0, 1)); }); it('should default to limit maxLimit, if more than maxLimit is given', async () => { const config = { sortableColumns: ['id'], defaultLimit: 5, maxLimit: 2, }; const query = { path: '', page: 1, limit: 20, }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data).toStrictEqual(cats.slice(0, 2)); }); it('should limit cats by query', async () => { const config = { sortableColumns: ['id'], maxLimit: Number.MAX_SAFE_INTEGER, defaultLimit: Number.MAX_SAFE_INTEGER, }; const query = { path: '', limit: 2, }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data).toStrictEqual(cats.slice(0, 2)); }); it('maxLimit should limit defaultLimit', async () => { const config = { sortableColumns: ['id'], maxLimit: 1, defaultLimit: 2, }; const query = { path: '', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data).toStrictEqual(cats.slice(0, 1)); }); it('limit should bypass defaultLimit', async () => { const config = { sortableColumns: ['id'], defaultLimit: 1, }; const query = { path: '', limit: 2, }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data).toStrictEqual(cats.slice(0, 2)); }); it('DEFAULT_LIMIT should be used as the limit if limit is set to NO_PAGINATION and maxLimit is not specified.', async () => { const config = { sortableColumns: ['id'], }; const query = { path: '', limit: paginate_1.PaginationLimit.NO_PAGINATION, }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data).toStrictEqual(cats.slice(0, global_config_1.default.defaultLimit)); }); it('should return the count without data ignoring maxLimit if limit is COUNTER_ONLY', async () => { const config = { sortableColumns: ['id'], maxLimit: paginate_1.PaginationLimit.NO_PAGINATION, }; const query = { path: '', limit: 0, }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data).toStrictEqual([]); expect(result.meta.totalItems).toBe(7); }); it('should return correct result for limited one-to-many relations', async () => { const config = { relations: ['toys'], sortableColumns: ['id', 'toys.id'], searchableColumns: ['name', 'toys.name'], defaultLimit: 4, }; const query = { path: '', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data.length).toStrictEqual(4); }); it('should return correct links for some results', async () => { const config = { sortableColumns: ['id'], }; const query = { path: '', page: 2, limit: 2, }; const { links } = await (0, paginate_1.paginate)(query, catRepo, config); expect(links.first).toBe('?page=1&limit=2&sortBy=id:ASC'); expect(links.previous).toBe('?page=1&limit=2&sortBy=id:ASC'); expect(links.current).toBe('?page=2&limit=2&sortBy=id:ASC'); expect(links.next).toBe('?page=3&limit=2&sortBy=id:ASC'); expect(links.last).toBe('?page=4&limit=2&sortBy=id:ASC'); }); it('should return a relative path', async () => { const config = { sortableColumns: ['id'], relativePath: true, }; const query = { path: 'http://localhost/cats', page: 2, limit: 2, }; const { links } = await (0, paginate_1.paginate)(query, catRepo, config); expect(links.first).toBe('/cats?page=1&limit=2&sortBy=id:ASC'); expect(links.previous).toBe('/cats?page=1&limit=2&sortBy=id:ASC'); expect(links.current).toBe('/cats?page=2&limit=2&sortBy=id:ASC'); expect(links.next).toBe('/cats?page=3&limit=2&sortBy=id:ASC'); expect(links.last).toBe('/cats?page=4&limit=2&sortBy=id:ASC'); }); it('should return an absolute path', async () => { const config = { sortableColumns: ['id'], relativePath: false, }; const query = { path: 'http://localhost/cats', page: 2, limit: 2, }; const { links } = await (0, paginate_1.paginate)(query, catRepo, config); expect(links.first).toBe('http://localhost/cats?page=1&limit=2&sortBy=id:ASC'); expect(links.previous).toBe('http://localhost/cats?page=1&limit=2&sortBy=id:ASC'); expect(links.current).toBe('http://localhost/cats?page=2&limit=2&sortBy=id:ASC'); expect(links.next).toBe('http://localhost/cats?page=3&limit=2&sortBy=id:ASC'); expect(links.last).toBe('http://localhost/cats?page=4&limit=2&sortBy=id:ASC'); }); it('should return an absolute path with new origin', async () => { const config = { sortableColumns: ['id'], relativePath: false, origin: 'http://cats.example', }; const query = { path: 'http://localhost/cats', page: 2, limit: 2, }; const { links } = await (0, paginate_1.paginate)(query, catRepo, config); expect(links.first).toBe('http://cats.example/cats?page=1&limit=2&sortBy=id:ASC'); expect(links.previous).toBe('http://cats.example/cats?page=1&limit=2&sortBy=id:ASC'); expect(links.current).toBe('http://cats.example/cats?page=2&limit=2&sortBy=id:ASC'); expect(links.next).toBe('http://cats.example/cats?page=3&limit=2&sortBy=id:ASC'); expect(links.last).toBe('http://cats.example/cats?page=4&limit=2&sortBy=id:ASC'); }); it('should return only current link if zero results', async () => { const config = { sortableColumns: ['id'], searchableColumns: ['name'], }; const query = { path: '', page: 1, limit: 2, search: 'Pluto', }; const { links } = await (0, paginate_1.paginate)(query, catRepo, config); expect(links.first).toBe(undefined); expect(links.previous).toBe(undefined); expect(links.current).toBe('?page=1&limit=2&sortBy=id:ASC&search=Pluto'); expect(links.next).toBe(undefined); expect(links.last).toBe(undefined); }); it('should default to defaultSortBy if query sortBy does not exist', async () => { const config = { sortableColumns: ['id', 'createdAt'], defaultSortBy: [['id', 'DESC']], }; const query = { path: '', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.meta.sortBy).toStrictEqual([['id', 'DESC']]); expect(result.data).toStrictEqual(cats.slice(0).reverse()); }); it('should put null values last when sorting', async () => { const config = { sortableColumns: ['age', 'createdAt'], nullSort: 'last', defaultSortBy: [['age', 'ASC']], }; const query = { path: '', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); // Extracting the indexes of non-null values ​​and null values const notNullIndexes = result.data .map((cat, index) => (cat.age !== null ? index : -1)) .filter((index) => index !== -1); const nullIndexes = result.data .map((cat, index) => (cat.age === null ? index : -1)) .filter((index) => index !== -1); expect(result.meta.sortBy).toStrictEqual([['age', 'ASC']]); expect(Math.max(...notNullIndexes)).toBeLessThan(Math.min(...nullIndexes)); }); it('should put null values first when sorting', async () => { const config = { sortableColumns: ['age', 'createdAt'], nullSort: 'first', defaultSortBy: [['age', 'ASC']], }; const query = { path: '', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); const nullIndexes = result.data .map((cat, index) => (cat.age === null ? index : -1)) .filter((index) => index !== -1); const notNullIndexes = result.data .map((cat, index) => (cat.age !== null ? index : -1)) .filter((index) => index !== -1); expect(result.meta.sortBy).toStrictEqual([['age', 'ASC']]); expect(Math.max(...nullIndexes)).toBeLessThan(Math.min(...notNullIndexes)); }); it('should sort result by multiple columns', async () => { const config = { sortableColumns: ['name', 'color'], }; const query = { path: '', sortBy: [ ['color', 'DESC'], ['name', 'ASC'], ], }; const result = await (0, paginate_1.paginate)(query, catRepo, config); const sortedCats = cats.slice(0).sort((a, b) => { if (a.color === b.color) { return a.name.localeCompare(b.name); } return b.color.localeCompare(a.color); }); expect(result.meta.sortBy).toStrictEqual([ ['color', 'DESC'], ['name', 'ASC'], ]); expect(result.data).toStrictEqual(sortedCats); }); it('should sort result by camelcase columns', async () => { const config = { sortableColumns: ['cutenessLevel', 'name'], }; const query = { path: '', sortBy: [ ['cutenessLevel', 'ASC'], ['name', 'ASC'], ], }; const result = await (0, paginate_1.paginate)(query, catRepo, config); const sortedCats = cats.slice(0).sort((a, b) => { if (a.cutenessLevel === b.cutenessLevel) { return a.name.localeCompare(b.name); } return a.cutenessLevel.localeCompare(b.cutenessLevel); }); expect(result.meta.sortBy).toStrictEqual([ ['cutenessLevel', 'ASC'], ['name', 'ASC'], ]); expect(result.data).toStrictEqual(sortedCats); }); describe('polymorphic sort (~)', () => { // Mirrors SQL COALESCE(bestFriend.age, nemesis.age) for the links set up in beforeAll. const coalescedAge = (cat) => { var _a, _b, _c, _d; return (_d = (_b = (_a = cat.bestFriend) === null || _a === void 0 ? void 0 : _a.age) !== null && _b !== void 0 ? _b : (_c = cat.nemesis) === null || _c === void 0 ? void 0 : _c.age) !== null && _d !== void 0 ? _d : null; }; it('should sort by a polymorphic column group using COALESCE (ASC)', async () => { const config = { sortableColumns: ['id', 'bestFriend.age', 'nemesis.age'], relations: ['bestFriend', 'nemesis'], }; const query = { path: '', sortBy: [[['bestFriend.age', 'nemesis.age'], 'ASC']], }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.meta.sortBy).toStrictEqual([[['bestFriend.age', 'nemesis.age'], 'ASC']]); const values = result.data.map(coalescedAge); // Every cat resolves to a non-null age through one of the two relations. expect(values.every((v) => typeof v === 'number')).toBe(true); // The COALESCE result is ordered ascending. expect(values).toStrictEqual([...values].sort((a, b) => a - b)); // The fallback actually fires: some rows resolve via nemesis (no bestFriend). expect(result.data.some((cat) => !cat.bestFriend && !!cat.nemesis)).toBe(true); }); it('should sort by a polymorphic column group using COALESCE (DESC)', async () => { const config = { sortableColumns: ['id', 'bestFriend.age', 'nemesis.age'], relations: ['bestFriend', 'nemesis'], }; const query = { path: '', sortBy: [[['bestFriend.age', 'nemesis.age'], 'DESC']], }; const result = await (0, paginate_1.paginate)(query, catRepo, config); const values = result.data.map(coalescedAge); expect(values).toStrictEqual([...values].sort((a, b) => b - a)); }); it('should ignore a polymorphic group when one of its columns is not sortable', async () => { const config = { sortableColumns: ['id', 'bestFriend.age'], // nemesis.age intentionally omitted relations: ['bestFriend', 'nemesis'], defaultSortBy: [['id', 'ASC']], }; const query = { path: '', sortBy: [[['bestFriend.age', 'nemesis.age'], 'ASC']], }; const result = await (0, paginate_1.paginate)(query, catRepo, config); // The group is dropped during validation, so the default sort applies. expect(result.meta.sortBy).toStrictEqual([['id', 'ASC']]); }); it('should reject a polymorphic group with cursor pagination', async () => { const config = { sortableColumns: ['id', 'bestFriend.age', 'nemesis.age'], relations: ['bestFriend', 'nemesis'], paginationType: paginate_1.PaginationType.CURSOR, }; const query = { path: '', sortBy: [[['bestFriend.age', 'nemesis.age'], 'ASC']], }; await expect((0, paginate_1.paginate)(query, catRepo, config)).rejects.toThrow('Polymorphic sort groups (using "~") are not supported with cursor pagination.'); }); it('should reject embedded columns inside a polymorphic group', async () => { const config = { sortableColumns: ['id', 'size.height', 'age'], }; const query = { path: '', sortBy: [[['size.height', 'age'], 'ASC']], }; await expect((0, paginate_1.paginate)(query, catRepo, config)).rejects.toThrow('Polymorphic sort groups (using "~") support only plain and relation columns'); }); }); it('should return result based on search term', async () => { const config = { sortableColumns: ['id', 'name', 'color'], searchableColumns: ['name', 'color'], }; const query = { path: '', search: 'i', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.meta.search).toStrictEqual('i'); expect(result.data).toStrictEqual([cats[0], cats[1], cats[3], cats[4]]); expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&search=i'); }); it('should return result based on search term on a camelcase named column', async () => { const config = { sortableColumns: ['id', 'name', 'color'], searchableColumns: ['cutenessLevel'], }; const query = { path: '', search: 'hi', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); const expectedCats = cats.filter((cat) => cat.cutenessLevel === cat_entity_1.CutenessLevel.HIGH); expect(result.meta.search).toStrictEqual('hi'); expect(result.data).toStrictEqual(expectedCats); expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&search=hi'); }); it('should not result in a sql syntax error when attempting a sql injection', async () => { const config = { sortableColumns: ['id', 'name', 'color'], searchableColumns: ['name', 'color'], }; const query = { path: '', search: "i UNION SELECT tbl_name FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%'", }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data).toStrictEqual([]); }); it('should return result based on search term on many-to-one relation', async () => { const config = { relations: ['cat'], sortableColumns: ['id', 'name'], searchableColumns: ['name', 'cat.name'], }; const query = { path: '', search: 'Milo', }; const result = await (0, paginate_1.paginate)(query, catToyRepo, config); expect(result.meta.search).toStrictEqual('Milo'); expect(result.data).toStrictEqual([catToysWithoutShop[0], catToysWithoutShop[1], catToysWithoutShop[2]]); expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&search=Milo'); }); it('should return result based on search term on one-to-many relation', async () => { const config = { relations: ['toys'], sortableColumns: ['id', 'toys.id'], searchableColumns: ['name', 'toys.name'], }; const query = { path: '', search: 'Mouse', sortBy: [ ['id', 'ASC'], ['toys.id', 'DESC'], ], }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.meta.search).toStrictEqual('Mouse'); const toy = (0, lodash_1.clone)(catToysWithoutShop[1]); delete toy.cat; const toy2 = (0, lodash_1.clone)(catToysWithoutShop[2]); delete toy2.cat; expect(result.data).toStrictEqual([Object.assign((0, lodash_1.clone)(cats[0]), { toys: [toy2, toy] })]); expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&sortBy=toys.id:DESC&search=Mouse'); }); it('should return result based on search term on one-to-one relation', async () => { const config = { relations: ['cat', 'naptimePillow.brand'], sortableColumns: ['id', 'name', 'cat.id'], }; const query = { path: '', sortBy: [['cat.id', 'DESC']], }; const result = await (0, paginate_1.paginate)(query, catHomeRepo, config); expect(result.meta.sortBy).toStrictEqual([['cat.id', 'DESC']]); const catHomesClone = (0, lodash_1.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'); }); it('should handle nullSort with relations properly', async () => { const config = { sortableColumns: ['id', 'age'], nullSort: 'last', defaultSortBy: [['age', 'ASC']], relations: ['toys'], }; const query = { path: '', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); // Prepare expected result - cats ordered by age with null age last, including toys relation const expectedResult = [...cats] .sort((a, b) => { if (a.age === null && b.age === null) return 0; if (a.age === null) return 1; if (b.age === null) return -1; return a.age - b.age; }) .map((cat) => cat.id); expect(result.meta.sortBy).toStrictEqual([['age', 'ASC']]); expect(result.data.map((v) => v.id)).toStrictEqual(expectedResult); }); it('should return result based on sort and search on many-to-one relation', async () => { const config = { relations: ['cat'], sortableColumns: ['id', 'name', 'cat.id'], searchableColumns: ['name', 'cat.name'], }; const query = { path: '', sortBy: [['cat.id', 'DESC']], search: 'Milo', }; const result = await (0, paginate_1.paginate)(query, catToyRepo, config); expect(result.meta.search).toStrictEqual('Milo'); expect(result.data).toStrictEqual([catToysWithoutShop[0], catToysWithoutShop[1], catToysWithoutShop[2]].sort((a, b) => b.cat.id - a.cat.id)); expect(result.links.current).toBe('?page=1&limit=20&sortBy=cat.id:DESC&search=Milo'); }); it('should return result based on sort on one-to-many relation', async () => { const config = { relations: ['toys', 'toys.shop', 'toys.shop.address'], sortableColumns: ['id', 'name', 'toys.id'], searchableColumns: ['name', 'toys.name'], }; const query = { path: '', sortBy: [['toys.id', 'DESC']], search: 'Mouse', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.meta.search).toStrictEqual('Mouse'); const toy1 = (0, lodash_1.clone)(catToys[1]); delete toy1.cat; const toy2 = (0, lodash_1.clone)(catToys[2]); delete toy2.cat; delete result.data[0].toys[0].shop.address; expect(result.data).toStrictEqual([Object.assign((0, lodash_1.clone)(cats[0]), { toys: [toy2, toy1] })]); expect(result.links.current).toBe('?page=1&limit=20&sortBy=toys.id:DESC&search=Mouse'); }); it('should return result based on sort on one-to-one relation', async () => { const config = { relations: ['cat', 'naptimePillow.brand'], sortableColumns: ['id', 'name'], searchableColumns: ['name', 'cat.name'], }; const query = { path: '', search: 'Garfield', }; const result = await (0, paginate_1.paginate)(query, catHomeRepo, config); expect(result.meta.search).toStrictEqual('Garfield'); const catHomesClone = (0, lodash_1.clone)(catHomes[1]); catHomesClone.countCat = cats.filter((cat) => cat.id === catHomesClone.cat.id).length; expect(result.data).toStrictEqual([catHomesClone]); expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&search=Garfield'); }); it('should load nested relations (object notation)', async () => { const config = { relations: { home: { pillows: true, naptimePillow: { brand: true } } }, sortableColumns: ['id', 'name'], searchableColumns: ['name'], }; const query = { path: '', search: 'Garfield', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); const cat = (0, lodash_1.clone)(cats[1]); const catHomesClone = (0, lodash_1.clone)(catHomes[1]); const catHomePillowsClone3 = (0, lodash_1.clone)(catHomePillows[3]); delete catHomePillowsClone3.home; const catHomePillowsClone4 = (0, lodash_1.clone)(catHomePillows[4]); delete catHomePillowsClone4.home; const catHomePillowsClone5 = (0, lodash_1.clone)(catHomePillows[5]); delete catHomePillowsClone5.home; catHomesClone.countCat = cats.filter((cat) => cat.id === catHomesClone.cat.id).length; catHomesClone.pillows = [catHomePillowsClone3, catHomePillowsClone4, catHomePillowsClone5]; cat.home = catHomesClone; delete cat.home.cat; expect(result.meta.search).toStrictEqual('Garfield'); expect(result.data).toStrictEqual([cat]); expect(result.data[0].home).toBeDefined(); expect(result.data[0].home.pillows).toStrictEqual(cat.home.pillows); }); it('should load nested relations (array notation)', async () => { const config = { relations: ['home.pillows', 'home.naptimePillow.brand'], sortableColumns: ['id', 'name'], searchableColumns: ['name'], }; const query = { path: '', search: 'Garfield', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); const cat = (0, lodash_1.clone)(cats[1]); const catHomesClone = (0, lodash_1.clone)(catHomes[1]); const catHomePillowsClone3 = (0, lodash_1.clone)(catHomePillows[3]); delete catHomePillowsClone3.home; const catHomePillowsClone4 = (0, lodash_1.clone)(catHomePillows[4]); delete catHomePillowsClone4.home; const catHomePillowsClone5 = (0, lodash_1.clone)(catHomePillows[5]); delete catHomePillowsClone5.home; catHomesClone.countCat = cats.filter((cat) => cat.id === catHomesClone.cat.id).length; catHomesClone.pillows = [catHomePillowsClone3, catHomePillowsClone4, catHomePillowsClone5]; cat.home = catHomesClone; delete cat.home.cat; expect(result.meta.search).toStrictEqual('Garfield'); expect(result.data).toStrictEqual([cat]); expect(result.data[0].home).toBeDefined(); expect(result.data[0].home.pillows).toStrictEqual(cat.home.pillows); }); it('should throw an error when nonexistent relation loaded', async () => { const config = { relations: ['homee'], sortableColumns: ['id'], }; const query = { path: '', }; try { await (0, paginate_1.paginate)(query, catRepo, config); } catch (err) { expect(err).toBeInstanceOf(typeorm_1.TypeORMError); } }); it('should return result based on search term and searchBy columns', async () => { const config = { sortableColumns: ['id', 'name', 'color'], searchableColumns: ['name', 'color'], }; const searchTerm = 'white'; const expectedResultData = cats.filter((cat) => cat.color === searchTerm); const query = { path: '', search: searchTerm, searchBy: ['color'], }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.meta.search).toStrictEqual(searchTerm); expect(result.meta.searchBy).toStrictEqual(['color']); expect(result.data).toStrictEqual(expectedResultData); expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&search=white&searchBy=color'); }); it('should return result based on where config and filter', async () => { const config = { sortableColumns: ['id'], where: { color: 'white', }, filterableColumns: { name: [filter_1.FilterSuffix.NOT], }, }; const query = { path: '', filter: { name: '$not:Leche', }, }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.meta.filter).toStrictEqual({ name: '$not:Leche', }); expect(result.data).toStrictEqual([cats[3]]); expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.name=$not:Leche'); }); it('should return based on a nested many-to-one where condition', async () => { const config = { sortableColumns: ['id'], relations: ['cat'], where: { cat: { id: cats[0].id, }, }, }; const query = { path: '', }; const result = await (0, paginate_1.paginate)(query, catToyRepo, config); expect(result.meta.totalItems).toBe(3); result.data.forEach((toy) => { expect(toy.cat.id).toBe(cats[0].id); }); expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC'); }); it('should return valid data filtering by not id field many-to-one', async () => { const config = { sortableColumns: ['id', 'name'], relations: ['cat'], where: { cat: { name: cats[0].name, }, }, }; const query = { path: '', }; const result = await (0, paginate_1.paginate)(query, catToyRepo, config); expect(result.meta.totalItems).toBe(3); result.data.forEach((toy) => { expect(toy.cat.id).toBe(cats[0].id); }); expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC'); }); it('should return result based on where one-to-many relation', async () => { const config = { relations: ['toys'], sortableColumns: ['id', 'name'], where: { toys: { name: 'Stuffed Mouse', }, }, }; const query = { path: '', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data.length).toBe(1); expect(result.data[0].toys.length).toBe(1); expect(result.data[0].toys[0].name).toBe('Stuffed Mouse'); }); it('should return all cats with a toys from the lovely shop', async () => { const config = { relations: ['toys', 'toys.shop'], sortableColumns: ['id', 'name'], where: { toys: { shop: { shopName: 'Lovely Toys', }, }, }, }; const query = { path: '', }; const result = await (0, paginate_1.paginate)(query, catRepo, config); expect(result.data.length).toBe(1); expect(result.data[0].toys.length).toBe(1); expect(result.data[0].toys[0].shop.id).toStrictEqual(toysShops[1].id); expect(result.data[0].toys[0].name).toBe('Mouse'); }); it('should return all cats from shop where street name like 123', async () => { const config = { relations: ['toys', 'toys.shop', 'toys.shop.address'], sortableColumns: ['id', 'name'], where: { to