From 9f9ae0d0722c685483f1b2e1bd501a0f3df3ff85 Mon Sep 17 00:00:00 2001 From: doug-martin Date: Sun, 26 Jul 2020 00:50:39 -0500 Subject: [PATCH] feat(core): Added applySort, applyPaging and applyQuery #405 --- package-lock.json | 16 + packages/core/__tests__/helpers.spec.ts | 749 ++++++++++++++++++++- packages/core/src/helpers/index.ts | 3 + packages/core/src/helpers/page.builder.ts | 11 + packages/core/src/helpers/query.helpers.ts | 28 +- packages/core/src/helpers/sort.builder.ts | 99 +++ packages/core/src/index.ts | 3 + 7 files changed, 904 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/helpers/page.builder.ts create mode 100644 packages/core/src/helpers/sort.builder.ts diff --git a/package-lock.json b/package-lock.json index 62b81d9c4..53b5e6ebe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8804,6 +8804,15 @@ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz", "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==" }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, "block-stream": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", @@ -12367,6 +12376,12 @@ "flat-cache": "^2.0.1" } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "optional": true + }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", @@ -23212,6 +23227,7 @@ "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "optional": true, "requires": { + "bindings": "^1.5.0", "nan": "^2.12.1" } }, diff --git a/packages/core/__tests__/helpers.spec.ts b/packages/core/__tests__/helpers.spec.ts index 5c6f87c9b..abb0a808f 100644 --- a/packages/core/__tests__/helpers.spec.ts +++ b/packages/core/__tests__/helpers.spec.ts @@ -1,11 +1,16 @@ import { AggregateResponse, applyFilter, + applyPaging, + applyQuery, + applySort, Filter, + Paging, Query, QueryFieldMap, SortDirection, SortField, + SortNulls, transformAggregateQuery, transformAggregateResponse, transformFilter, @@ -16,11 +21,15 @@ import { getFilterFields } from '../src/helpers/query.helpers'; import { AggregateQuery } from '../src/interfaces/aggregate-query.interface'; class TestDTO { - first!: string; + first?: string | null; - last!: string; + last?: string | null; - age?: number; + age?: number | null; + + isVerified?: boolean | null; + + created?: Date | null; } class TestEntity { @@ -504,3 +513,737 @@ describe('transformAggregateResponse', () => { ); }); }); + +describe('applySort', () => { + type TestCase = { description: string; sortFields: SortField[]; input: TestDTO[]; expected: TestDTO[] }; + + const date = (day: number): Date => new Date(`2020-1-${day}`); + + describe('sort asc', () => { + const testCases: TestCase[] = [ + { + description: 'sort strings asc', + sortFields: [{ field: 'first', direction: SortDirection.ASC }], + input: [{ first: 'bob' }, { first: 'sally' }, { first: 'zane' }, { first: 'alice' }], + expected: [{ first: 'alice' }, { first: 'bob' }, { first: 'sally' }, { first: 'zane' }], + }, + { + description: 'sort strings with nulls asc', + sortFields: [{ field: 'first', direction: SortDirection.ASC }], + input: [{ first: 'bob' }, { first: 'sally' }, { first: 'zane' }, { first: 'alice' }, { first: null }, {}], + expected: [{ first: 'alice' }, { first: 'bob' }, { first: 'sally' }, { first: 'zane' }, { first: null }, {}], + }, + { + description: 'sort strings with nulls first asc', + sortFields: [{ field: 'first', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }], + input: [{ first: 'bob' }, { first: 'sally' }, { first: 'zane' }, { first: 'alice' }, { first: null }, {}], + expected: [{}, { first: null }, { first: 'alice' }, { first: 'bob' }, { first: 'sally' }, { first: 'zane' }], + }, + { + description: 'sort strings with nulls last asc', + sortFields: [{ field: 'first', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }], + input: [{ first: 'bob' }, { first: 'sally' }, { first: 'zane' }, { first: 'alice' }, { first: null }, {}], + expected: [{ first: 'alice' }, { first: 'bob' }, { first: 'sally' }, { first: 'zane' }, { first: null }, {}], + }, + { + description: 'sort numbers asc', + sortFields: [{ field: 'age', direction: SortDirection.ASC }], + input: [{ age: 30 }, { age: 33 }, { age: 31 }, { age: 32 }], + expected: [{ age: 30 }, { age: 31 }, { age: 32 }, { age: 33 }], + }, + { + description: 'sort numbers with nulls asc', + sortFields: [{ field: 'age', direction: SortDirection.ASC }], + input: [{ age: 30 }, { age: 33 }, { age: 31 }, { age: 32 }, { age: null }, {}], + expected: [{ age: 30 }, { age: 31 }, { age: 32 }, { age: 33 }, { age: null }, {}], + }, + { + description: 'sort numbers with nulls first asc', + sortFields: [{ field: 'age', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }], + input: [{ age: 30 }, { age: 33 }, { age: 31 }, { age: 32 }, { age: null }, {}], + expected: [{}, { age: null }, { age: 30 }, { age: 31 }, { age: 32 }, { age: 33 }], + }, + { + description: 'sort numbers with nulls last asc', + sortFields: [{ field: 'age', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }], + input: [{ age: 30 }, { age: 33 }, { age: 31 }, { age: 32 }, { age: null }, {}], + expected: [{ age: 30 }, { age: 31 }, { age: 32 }, { age: 33 }, { age: null }, {}], + }, + { + description: 'sort booleans asc', + sortFields: [{ field: 'isVerified', direction: SortDirection.ASC }], + input: [{ isVerified: true }, { isVerified: false }, { isVerified: false }, { isVerified: true }], + expected: [{ isVerified: false }, { isVerified: false }, { isVerified: true }, { isVerified: true }], + }, + { + description: 'sort booleans with nulls asc', + sortFields: [{ field: 'isVerified', direction: SortDirection.ASC }], + input: [ + { isVerified: true }, + { isVerified: false }, + { isVerified: false }, + { isVerified: true }, + { isVerified: null }, + {}, + ], + expected: [ + { isVerified: false }, + { isVerified: false }, + { isVerified: true }, + { isVerified: true }, + { isVerified: null }, + {}, + ], + }, + { + description: 'sort booleans with nulls first asc', + sortFields: [{ field: 'isVerified', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }], + input: [ + { isVerified: true }, + { isVerified: false }, + { isVerified: false }, + { isVerified: true }, + { isVerified: null }, + {}, + ], + expected: [ + {}, + { isVerified: null }, + { isVerified: false }, + { isVerified: false }, + { isVerified: true }, + { isVerified: true }, + ], + }, + { + description: 'sort booleans with nulls last asc', + sortFields: [{ field: 'isVerified', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }], + input: [ + { isVerified: true }, + { isVerified: false }, + { isVerified: false }, + { isVerified: true }, + { isVerified: null }, + {}, + ], + expected: [ + { isVerified: false }, + { isVerified: false }, + { isVerified: true }, + { isVerified: true }, + { isVerified: null }, + {}, + ], + }, + { + description: 'sort dates asc', + sortFields: [{ field: 'created', direction: SortDirection.ASC }], + input: [{ created: date(4) }, { created: date(2) }, { created: date(3) }, { created: date(1) }], + expected: [{ created: date(1) }, { created: date(2) }, { created: date(3) }, { created: date(4) }], + }, + { + description: 'sort dates with nulls asc', + sortFields: [{ field: 'created', direction: SortDirection.ASC }], + input: [ + { created: date(4) }, + { created: date(2) }, + { created: date(3) }, + { created: date(1) }, + { created: null }, + {}, + ], + expected: [ + { created: date(1) }, + { created: date(2) }, + { created: date(3) }, + { created: date(4) }, + { created: null }, + {}, + ], + }, + { + description: 'sort dates with nulls first asc', + sortFields: [{ field: 'created', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }], + input: [ + { created: date(4) }, + { created: date(2) }, + { created: date(3) }, + { created: date(1) }, + { created: null }, + {}, + ], + expected: [ + {}, + { created: null }, + { created: date(1) }, + { created: date(2) }, + { created: date(3) }, + { created: date(4) }, + ], + }, + { + description: 'sort dates with nulls last asc', + sortFields: [{ field: 'created', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }], + input: [ + { created: date(4) }, + { created: date(2) }, + { created: date(3) }, + { created: date(1) }, + { created: null }, + {}, + ], + expected: [ + { created: date(1) }, + { created: date(2) }, + { created: date(3) }, + { created: date(4) }, + { created: null }, + {}, + ], + }, + ]; + testCases.forEach(({ description, input, expected, sortFields }) => { + it(`should ${description}`, () => { + expect(applySort(input, sortFields)).toEqual(expected); + }); + }); + }); + + describe('should sort desc', () => { + const testCases: TestCase[] = [ + { + description: 'sort strings desc', + sortFields: [{ field: 'first', direction: SortDirection.DESC }], + input: [{ first: 'bob' }, { first: 'sally' }, { first: 'zane' }, { first: 'alice' }], + expected: [{ first: 'zane' }, { first: 'sally' }, { first: 'bob' }, { first: 'alice' }], + }, + { + description: 'sort strings with nulls desc', + sortFields: [{ field: 'first', direction: SortDirection.DESC }], + input: [{ first: 'bob' }, { first: 'sally' }, { first: 'zane' }, { first: 'alice' }, { first: null }, {}], + expected: [{}, { first: null }, { first: 'zane' }, { first: 'sally' }, { first: 'bob' }, { first: 'alice' }], + }, + { + description: 'sort strings with nulls first desc', + sortFields: [{ field: 'first', direction: SortDirection.DESC, nulls: SortNulls.NULLS_FIRST }], + input: [{ first: 'bob' }, { first: 'sally' }, { first: 'zane' }, { first: 'alice' }, { first: null }, {}], + expected: [{}, { first: null }, { first: 'zane' }, { first: 'sally' }, { first: 'bob' }, { first: 'alice' }], + }, + { + description: 'sort strings with nulls last desc', + sortFields: [{ field: 'first', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }], + input: [{ first: 'bob' }, { first: 'sally' }, { first: 'zane' }, { first: 'alice' }, { first: null }, {}], + expected: [{ first: 'zane' }, { first: 'sally' }, { first: 'bob' }, { first: 'alice' }, { first: null }, {}], + }, + { + description: 'sort numbers desc', + sortFields: [{ field: 'age', direction: SortDirection.DESC }], + input: [{ age: 30 }, { age: 33 }, { age: 31 }, { age: 32 }], + expected: [{ age: 33 }, { age: 32 }, { age: 31 }, { age: 30 }], + }, + { + description: 'sort numbers with nulls desc', + sortFields: [{ field: 'age', direction: SortDirection.DESC }], + input: [{ age: 30 }, { age: 33 }, { age: 31 }, { age: 32 }, { age: null }, {}], + expected: [{}, { age: null }, { age: 33 }, { age: 32 }, { age: 31 }, { age: 30 }], + }, + { + description: 'sort numbers with nulls first desc', + sortFields: [{ field: 'age', direction: SortDirection.DESC, nulls: SortNulls.NULLS_FIRST }], + input: [{ age: 30 }, { age: 33 }, { age: 31 }, { age: 32 }, { age: null }, {}], + expected: [{}, { age: null }, { age: 33 }, { age: 32 }, { age: 31 }, { age: 30 }], + }, + { + description: 'sort numbers with nulls last desc', + sortFields: [{ field: 'age', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }], + input: [{ age: 30 }, { age: 33 }, { age: 31 }, { age: 32 }, { age: null }, {}], + expected: [{ age: 33 }, { age: 32 }, { age: 31 }, { age: 30 }, { age: null }, {}], + }, + { + description: 'sort booleans desc', + sortFields: [{ field: 'isVerified', direction: SortDirection.DESC }], + input: [{ isVerified: true }, { isVerified: false }, { isVerified: false }, { isVerified: true }], + expected: [{ isVerified: true }, { isVerified: true }, { isVerified: false }, { isVerified: false }], + }, + { + description: 'sort booleans with nulls desc', + sortFields: [{ field: 'isVerified', direction: SortDirection.DESC }], + input: [ + { isVerified: true }, + { isVerified: false }, + { isVerified: false }, + { isVerified: true }, + { isVerified: null }, + {}, + ], + expected: [ + {}, + { isVerified: null }, + { isVerified: true }, + { isVerified: true }, + { isVerified: false }, + { isVerified: false }, + ], + }, + { + description: 'sort booleans with nulls first desc', + sortFields: [{ field: 'isVerified', direction: SortDirection.DESC, nulls: SortNulls.NULLS_FIRST }], + input: [ + { isVerified: true }, + { isVerified: false }, + { isVerified: false }, + { isVerified: true }, + { isVerified: null }, + {}, + ], + expected: [ + {}, + { isVerified: null }, + { isVerified: true }, + { isVerified: true }, + { isVerified: false }, + { isVerified: false }, + ], + }, + { + description: 'sort booleans with nulls last desc', + sortFields: [{ field: 'isVerified', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }], + input: [ + { isVerified: true }, + { isVerified: true }, + { isVerified: null }, + { isVerified: false }, + { isVerified: false }, + {}, + ], + expected: [ + { isVerified: true }, + { isVerified: true }, + { isVerified: false }, + { isVerified: false }, + { isVerified: null }, + {}, + ], + }, + { + description: 'sort dates desc', + sortFields: [{ field: 'created', direction: SortDirection.DESC }], + input: [{ created: date(4) }, { created: date(2) }, { created: date(3) }, { created: date(1) }], + expected: [{ created: date(4) }, { created: date(3) }, { created: date(2) }, { created: date(1) }], + }, + { + description: 'sort dates with nulls desc', + sortFields: [{ field: 'created', direction: SortDirection.DESC }], + input: [ + { created: date(4) }, + { created: date(2) }, + { created: date(3) }, + { created: date(1) }, + { created: null }, + {}, + ], + expected: [ + {}, + { created: null }, + { created: date(4) }, + { created: date(3) }, + { created: date(2) }, + { created: date(1) }, + ], + }, + { + description: 'sort dates with nulls first desc', + sortFields: [{ field: 'created', direction: SortDirection.DESC, nulls: SortNulls.NULLS_FIRST }], + input: [ + { created: date(4) }, + { created: date(2) }, + { created: date(3) }, + { created: date(1) }, + { created: null }, + {}, + ], + expected: [ + {}, + { created: null }, + { created: date(4) }, + { created: date(3) }, + { created: date(2) }, + { created: date(1) }, + ], + }, + { + description: 'sort dates with nulls last desc', + sortFields: [{ field: 'created', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }], + input: [ + { created: date(4) }, + { created: date(2) }, + { created: date(3) }, + { created: date(1) }, + { created: null }, + {}, + ], + expected: [ + { created: date(4) }, + { created: date(3) }, + { created: date(2) }, + { created: date(1) }, + { created: null }, + {}, + ], + }, + ]; + testCases.forEach(({ description, input, expected, sortFields }) => { + it(`should ${description}`, () => { + expect(applySort(input, sortFields)).toEqual(expected); + }); + }); + }); + + describe('multi sort', () => { + const testCases: TestCase[] = [ + { + description: 'sort multiple fields asc', + sortFields: [ + { field: 'first', direction: SortDirection.ASC }, + { field: 'last', direction: SortDirection.ASC }, + ], + input: [ + { first: 'd', last: 'a' }, + { first: 'a', last: 'a' }, + { first: 'b', last: 'a' }, + { first: 'c', last: 'a' }, + { first: 'd', last: 'b' }, + { first: 'a', last: 'b' }, + { first: 'c', last: 'b' }, + { first: 'b', last: 'b' }, + { first: 'd', last: 'c' }, + { first: 'c', last: 'c' }, + { first: 'a', last: 'c' }, + { first: 'b', last: 'c' }, + ], + expected: [ + { first: 'a', last: 'a' }, + { first: 'a', last: 'b' }, + { first: 'a', last: 'c' }, + { first: 'b', last: 'a' }, + { first: 'b', last: 'b' }, + { first: 'b', last: 'c' }, + { first: 'c', last: 'a' }, + { first: 'c', last: 'b' }, + { first: 'c', last: 'c' }, + { first: 'd', last: 'a' }, + { first: 'd', last: 'b' }, + { first: 'd', last: 'c' }, + ], + }, + { + description: 'sort multiple fields desc', + sortFields: [ + { field: 'first', direction: SortDirection.DESC }, + { field: 'last', direction: SortDirection.DESC }, + ], + input: [ + { first: 'd', last: 'a' }, + { first: 'a', last: 'a' }, + { first: 'b', last: 'a' }, + { first: 'c', last: 'a' }, + { first: 'd', last: 'b' }, + { first: 'a', last: 'b' }, + { first: 'c', last: 'b' }, + { first: 'b', last: 'b' }, + { first: 'd', last: 'c' }, + { first: 'c', last: 'c' }, + { first: 'a', last: 'c' }, + { first: 'b', last: 'c' }, + ], + expected: [ + { first: 'd', last: 'c' }, + { first: 'd', last: 'b' }, + { first: 'd', last: 'a' }, + { first: 'c', last: 'c' }, + { first: 'c', last: 'b' }, + { first: 'c', last: 'a' }, + { first: 'b', last: 'c' }, + { first: 'b', last: 'b' }, + { first: 'b', last: 'a' }, + { first: 'a', last: 'c' }, + { first: 'a', last: 'b' }, + { first: 'a', last: 'a' }, + ], + }, + { + description: 'sort multiple fields asc and desc', + sortFields: [ + { field: 'first', direction: SortDirection.DESC }, + { field: 'last', direction: SortDirection.ASC }, + ], + input: [ + { first: 'd', last: 'a' }, + { first: 'a', last: 'a' }, + { first: 'b', last: 'a' }, + { first: 'c', last: 'a' }, + { first: 'd', last: 'b' }, + { first: 'a', last: 'b' }, + { first: 'c', last: 'b' }, + { first: 'b', last: 'b' }, + { first: 'd', last: 'c' }, + { first: 'c', last: 'c' }, + { first: 'a', last: 'c' }, + { first: 'b', last: 'c' }, + ], + expected: [ + { first: 'd', last: 'a' }, + { first: 'd', last: 'b' }, + { first: 'd', last: 'c' }, + { first: 'c', last: 'a' }, + { first: 'c', last: 'b' }, + { first: 'c', last: 'c' }, + { first: 'b', last: 'a' }, + { first: 'b', last: 'b' }, + { first: 'b', last: 'c' }, + { first: 'a', last: 'a' }, + { first: 'a', last: 'b' }, + { first: 'a', last: 'c' }, + ], + }, + { + description: 'sort multiple fields asc nulls first and desc nulls last', + sortFields: [ + { field: 'first', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }, + { field: 'last', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }, + ], + input: [ + { first: 'd' }, + { first: 'a' }, + { first: 'b' }, + { first: 'c', last: null }, + { first: 'a', last: 'a' }, + { first: 'c', last: 'b' }, + { first: 'b', last: 'b' }, + { first: 'c' }, + { first: 'a', last: null }, + { first: 'c', last: 'c' }, + { last: 'a' }, + { first: 'd', last: 'a' }, + { last: null }, + { first: 'd', last: 'b' }, + { last: 'b' }, + {}, + { last: 'c' }, + { first: 'b', last: 'c' }, + { first: 'd', last: 'c' }, + { first: 'b', last: 'a' }, + { first: 'a', last: 'b' }, + { first: 'd', last: null }, + { first: 'b', last: null }, + { first: 'a', last: 'c' }, + { first: 'c', last: 'a' }, + ], + expected: [ + { first: 'd' }, + { first: 'd', last: null }, + { first: 'd', last: 'a' }, + { first: 'd', last: 'b' }, + { first: 'd', last: 'c' }, + { first: 'c' }, + { first: 'c', last: null }, + { first: 'c', last: 'a' }, + { first: 'c', last: 'b' }, + { first: 'c', last: 'c' }, + { first: 'b' }, + { first: 'b', last: null }, + { first: 'b', last: 'a' }, + { first: 'b', last: 'b' }, + { first: 'b', last: 'c' }, + { first: 'a' }, + { first: 'a', last: null }, + { first: 'a', last: 'a' }, + { first: 'a', last: 'b' }, + { first: 'a', last: 'c' }, + {}, + { last: null }, + { last: 'a' }, + { last: 'b' }, + { last: 'c' }, + ], + }, + { + description: 'sort multiple fields with all first columns null', + sortFields: [ + { field: 'first', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }, + { field: 'last', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }, + ], + input: [{ last: 'a' }, { last: null }, { last: 'b' }, {}, { last: 'c' }], + expected: [{}, { last: null }, { last: 'a' }, { last: 'b' }, { last: 'c' }], + }, + ]; + testCases.forEach(({ description, input, expected, sortFields }) => { + it(`should ${description}`, () => { + expect(applySort(input, sortFields)).toEqual(expected); + }); + }); + }); +}); + +describe('applyPaging', () => { + type TestCase = { description: string; paging: Paging; input: TestDTO[]; expected: TestDTO[] }; + const testCases: TestCase[] = [ + { + description: 'return all elements if paging is empty', + paging: {}, + input: [ + { first: 'bob', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + ], + expected: [ + { first: 'bob', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + ], + }, + { + description: 'apply a limit', + paging: { limit: 3 }, + input: [ + { first: 'bob', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + ], + expected: [ + { first: 'bob', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + ], + }, + { + description: 'apply an offset', + paging: { offset: 2 }, + input: [ + { first: 'bob', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + ], + expected: [ + { first: 'alice', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + ], + }, + { + description: 'apply a limit and offset', + paging: { offset: 1, limit: 2 }, + input: [ + { first: 'bob', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + ], + expected: [ + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + ], + }, + ]; + testCases.forEach(({ description, input, expected, paging }) => { + it(`should ${description}`, () => { + expect(applyPaging(input, paging)).toEqual(expected); + }); + }); +}); + +describe('applyQuery', () => { + type TestCase = { description: string; query: Query; input: TestDTO[]; expected: TestDTO[] }; + const testCases: TestCase[] = [ + { + description: 'return all elements if the query is empty', + query: {}, + input: [ + { first: 'bob', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + ], + expected: [ + { first: 'bob', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + ], + }, + { + description: 'apply a filter', + query: { filter: { first: { in: ['bob', 'alice'] } } }, + input: [ + { first: 'bob', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + ], + expected: [ + { first: 'bob', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + ], + }, + { + description: 'apply sorting', + query: { sorting: [{ field: 'first', direction: SortDirection.ASC }] }, + input: [ + { first: 'bob', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + ], + expected: [ + { first: 'alice', last: 'yukon' }, + { first: 'bob', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + ], + }, + { + description: 'apply paging', + query: { paging: { offset: 1, limit: 2 } }, + input: [ + { first: 'bob', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + ], + expected: [ + { first: 'sally', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + ], + }, + { + description: 'apply filter, sorting and paging', + query: { + filter: { first: { in: ['bob', 'sally', 'alice', 'zane'] } }, + sorting: [{ field: 'first', direction: SortDirection.DESC }], + paging: { offset: 1, limit: 2 }, + }, + input: [ + { first: 'bob', last: 'yukon' }, + { first: 'bill', last: 'yukon' }, + { first: 'sally', last: 'yukon' }, + { first: 'sue', last: 'yukon' }, + { first: 'alice', last: 'yukon' }, + { first: 'alex', last: 'yukon' }, + { first: 'zane', last: 'yukon' }, + { first: 'zeb', last: 'yukon' }, + ], + expected: [ + { first: 'sally', last: 'yukon' }, + { first: 'bob', last: 'yukon' }, + ], + }, + ]; + testCases.forEach(({ description, input, expected, query }) => { + it(`should ${description}`, () => { + expect(applyQuery(input, query)).toEqual(expected); + }); + }); +}); diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index 4afb152c7..1c1a5eba5 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -6,5 +6,8 @@ export { transformQuery, transformSort, getFilterFields, + applySort, + applyPaging, + applyQuery, } from './query.helpers'; export { transformAggregateQuery, transformAggregateResponse } from './aggregate.helpers'; diff --git a/packages/core/src/helpers/page.builder.ts b/packages/core/src/helpers/page.builder.ts new file mode 100644 index 000000000..6da46afdc --- /dev/null +++ b/packages/core/src/helpers/page.builder.ts @@ -0,0 +1,11 @@ +import { Paging } from '../interfaces'; + +type Pager = (dtos: DTO[]) => DTO[]; +export class PageBuilder { + static build(paging: Paging): Pager { + return (dtos: DTO[]): DTO[] => { + const { limit = dtos.length, offset = 0 } = paging; + return dtos.slice(offset, limit + offset); + }; + } +} diff --git a/packages/core/src/helpers/query.helpers.ts b/packages/core/src/helpers/query.helpers.ts index 5be6811eb..3a06fc9cf 100644 --- a/packages/core/src/helpers/query.helpers.ts +++ b/packages/core/src/helpers/query.helpers.ts @@ -1,6 +1,8 @@ import merge from 'lodash.merge'; -import { Filter, Query, SortField } from '../interfaces'; +import { Filter, Paging, Query, SortField } from '../interfaces'; import { FilterBuilder } from './filter.builder'; +import { SortBuilder } from './sort.builder'; +import { PageBuilder } from './page.builder'; export type QueryFieldMap = { [F in keyof From]?: T; @@ -71,4 +73,26 @@ export const getFilterFields = (filter: Filter): string[] => { return [...fieldSet]; }; -export const applyFilter = (dto: DTO, filter: Filter): boolean => FilterBuilder.build(filter)(dto); +export function applyFilter(dto: DTO[], filter: Filter): DTO[]; +export function applyFilter(dto: DTO, filter: Filter): boolean; +export function applyFilter(dtoOrArray: DTO | DTO[], filter: Filter): boolean | DTO[] { + const filterFunc = FilterBuilder.build(filter); + if (Array.isArray(dtoOrArray)) { + return dtoOrArray.filter((dto) => filterFunc(dto)); + } + return filterFunc(dtoOrArray); +} + +export const applySort = (dtos: DTO[], sortFields: SortField[]): DTO[] => { + return SortBuilder.build(sortFields)(dtos); +}; + +export const applyPaging = (dtos: DTO[], paging: Paging): DTO[] => { + return PageBuilder.build(paging)(dtos); +}; + +export const applyQuery = (dtos: DTO[], query: Query): DTO[] => { + const filtered = applyFilter(dtos, query.filter ?? {}); + const sorted = applySort(filtered, query.sorting ?? []); + return applyPaging(sorted, query.paging ?? {}); +}; diff --git a/packages/core/src/helpers/sort.builder.ts b/packages/core/src/helpers/sort.builder.ts new file mode 100644 index 000000000..96c1644c2 --- /dev/null +++ b/packages/core/src/helpers/sort.builder.ts @@ -0,0 +1,99 @@ +import { SortDirection, SortField, SortNulls } from '../interfaces'; + +type SortResult = -1 | 0 | 1; +type SortComparator = (a: Field, b: Field) => SortResult; +type Sorter = (dtos: DTO[]) => DTO[]; + +function isNullish(a: unknown): a is null | undefined { + return a === null || a === undefined; +} + +function nullComparator(a: null | undefined, b: null | undefined) { + if (a === b) { + return 0; + } + return a === null ? 1 : -1; +} + +function nullsFirstSort(a: unknown, b: unknown): SortResult { + if (!(isNullish(a) || isNullish(b))) { + return 0; + } + if (isNullish(a) && isNullish(b)) { + return nullComparator(a, b); + } + return isNullish(a) ? -1 : 1; +} +function nullsLastSort(a: unknown, b: unknown): SortResult { + return (nullsFirstSort(a, b) * -1) as SortResult; +} + +function ascSort(a: Field, b: Field): SortResult { + if (a === b) { + return 0; + } + return a < b ? -1 : 1; +} + +function descSort(a: Field, b: Field): SortResult { + return (ascSort(a, b) * -1) as SortResult; +} + +export class SortBuilder { + static build(sorts: SortField[]): Sorter { + const comparators = sorts.map(({ field, direction, nulls }) => this.buildComparator(field, direction, nulls)); + const comparator: SortComparator = (a, b): SortResult => { + return comparators.reduce((result: SortResult, cmp) => { + if (result === 0) { + return cmp(a, b); + } + return result; + }, 0); + }; + return (dtos: DTO[]): DTO[] => [...dtos].sort(comparator); + } + + static buildComparator( + field: keyof DTO, + direction: SortDirection, + nulls?: SortNulls, + ): SortComparator { + const nullSort = this.nullsComparator(direction, nulls); + const fieldValueComparator = this.fieldValueComparator(field, direction); + return (a, b): SortResult => { + const aField = a[field]; + const bField = b[field]; + const nullResult = nullSort(aField, bField); + if (nullResult !== 0) { + return nullResult; + } + return fieldValueComparator(aField, bField); + }; + } + + static fieldValueComparator( + field: keyof DTO, + direction: SortDirection, + ): SortComparator { + if (direction === SortDirection.ASC) { + return (a, b) => ascSort(a, b); + } + return (a, b) => descSort(a, b); + } + + static nullsComparator(direction: SortDirection, nulls?: SortNulls): SortComparator { + switch (nulls) { + case SortNulls.NULLS_FIRST: + return nullsFirstSort; + case SortNulls.NULLS_LAST: + return nullsLastSort; + default: + switch (direction) { + case SortDirection.DESC: + return nullsFirstSort; + default: + return nullsLastSort; + } + } + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ab1ee0f08..18f4ac83a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,9 @@ export { QueryFieldMap, transformAggregateQuery, transformAggregateResponse, + applySort, + applyPaging, + applyQuery, } from './helpers'; export { ClassTransformerAssembler,