From 40d56c438c25c4a35fdef1a6b3c2ab72e02ebdf7 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Tue, 18 Oct 2022 09:30:49 +0200 Subject: [PATCH] fix: query parameter building + enhanced generation --- src/build/module.ts | 54 ++++++++++-------- src/parameter/fields/build.ts | 69 ++++++++++++---------- src/parameter/fields/type.ts | 3 +- src/parameter/filters/build.ts | 63 +++++++++----------- src/parameter/filters/utils/operator.ts | 2 +- src/parameter/pagination/build.ts | 20 ++----- src/parameter/relations/build.ts | 28 ++++----- src/parameter/relations/type.ts | 6 +- src/parameter/sort/build.ts | 60 ++++++++++--------- src/parameter/sort/type.ts | 3 +- src/type.ts | 4 +- src/utils/array.ts | 76 ++++++++++++++++++++----- src/utils/index.ts | 1 - src/utils/merge-deep.ts | 42 -------------- src/utils/object.ts | 12 ++-- test/unit/build.spec.ts | 38 ++++++++----- 16 files changed, 248 insertions(+), 233 deletions(-) delete mode 100644 src/utils/merge-deep.ts diff --git a/src/build/module.ts b/src/build/module.ts index a95866fd..8aef8905 100644 --- a/src/build/module.ts +++ b/src/build/module.ts @@ -7,11 +7,15 @@ import { BuildInput, BuildOptions } from './type'; import { - buildQueryFieldsForMany, - buildQueryFiltersForMany, - buildQueryPaginationForMany, - buildQueryRelationsForMany, - buildQuerySortForMany, + buildQueryFields, + buildQueryFilters, + buildQueryRelations, + buildQuerySort, + mergeQueryFields, + mergeQueryFilters, + mergeQueryPagination, + mergeQueryRelations, + mergeQuerySort, } from '../parameter'; import { Parameter, URLParameter } from '../constants'; import { @@ -35,50 +39,50 @@ export function buildQuery>( typeof input[Parameter.FIELDS] !== 'undefined' || typeof input[URLParameter.FIELDS] !== 'undefined' ) { - query[URLParameter.FIELDS] = buildQueryFieldsForMany([ - ...(input[Parameter.FIELDS] ? [input[Parameter.FIELDS]] : []), - ...(input[URLParameter.FIELDS] ? [input[URLParameter.FIELDS]] : []), - ]); + query[URLParameter.FIELDS] = mergeQueryFields( + buildQueryFields(input[Parameter.FIELDS]), + buildQueryFields(input[URLParameter.FIELDS]), + ); } if ( typeof input[Parameter.FILTERS] !== 'undefined' || typeof input[URLParameter.FILTERS] !== 'undefined' ) { - query[URLParameter.FILTERS] = buildQueryFiltersForMany([ - ...(input[Parameter.FILTERS] ? [input[Parameter.FILTERS]] : []), - ...(input[URLParameter.FILTERS] ? [input[URLParameter.FILTERS]] : []), - ]); + query[URLParameter.FILTERS] = mergeQueryFilters( + buildQueryFilters(input[Parameter.FILTERS]), + buildQueryFilters(input[URLParameter.FILTERS]), + ); } if ( typeof input[Parameter.PAGINATION] !== 'undefined' || typeof input[URLParameter.PAGINATION] !== 'undefined' ) { - query[URLParameter.PAGINATION] = buildQueryPaginationForMany([ - ...(input[Parameter.PAGINATION] ? [input[Parameter.PAGINATION]] : []), - ...(input[URLParameter.PAGINATION] ? [input[URLParameter.PAGINATION]] : []), - ]); + query[URLParameter.PAGINATION] = mergeQueryPagination( + input[Parameter.PAGINATION], + input[URLParameter.PAGINATION], + ); } if ( typeof input[Parameter.RELATIONS] !== 'undefined' || typeof input[URLParameter.RELATIONS] !== 'undefined' ) { - query[URLParameter.RELATIONS] = buildQueryRelationsForMany([ - ...(input[Parameter.RELATIONS] ? [input[Parameter.RELATIONS]] : []), - ...(input[URLParameter.RELATIONS] ? [input[URLParameter.RELATIONS]] : []), - ]); + query[URLParameter.RELATIONS] = mergeQueryRelations( + buildQueryRelations(input[Parameter.RELATIONS]), + buildQueryRelations(input[URLParameter.RELATIONS]), + ); } if ( typeof input[Parameter.SORT] !== 'undefined' || typeof input[URLParameter.SORT] !== 'undefined' ) { - query[URLParameter.SORT] = buildQuerySortForMany([ - ...(input[Parameter.SORT] ? [input[Parameter.SORT]] : []), - ...(input[URLParameter.SORT] ? [input[URLParameter.SORT]] : []), - ]); + query[URLParameter.SORT] = mergeQuerySort( + buildQuerySort(input[Parameter.SORT]), + buildQuerySort(input[URLParameter.SORT]), + ); } return buildURLQueryString(query); diff --git a/src/parameter/fields/build.ts b/src/parameter/fields/build.ts index 44af44d2..014a2aa0 100644 --- a/src/parameter/fields/build.ts +++ b/src/parameter/fields/build.ts @@ -5,39 +5,50 @@ * view the LICENSE file that was distributed with this source code. */ +import { createMerger } from 'smob'; import { FieldsBuildInput } from './type'; -import { flattenNestedObject, mergeDeep } from '../../utils'; - -export function buildQueryFieldsForMany( - inputs: FieldsBuildInput[], -): Record | string | string[] { - let data: FieldsBuildInput; - - for (let i = 0; i < inputs.length; i++) { - if (data) { - const current = inputs[i]; - if (typeof data === 'string' || typeof current === 'string') { - data = inputs[i]; - } else { - data = mergeDeep(data, current); - } - } else { - data = inputs[i]; - } +import { flattenToKeyPathArray, groupArrayByKeyPath } from '../../utils'; + +export function buildQueryFields( + input?: FieldsBuildInput, +) : Record | string[] { + if (typeof input === 'undefined') { + return []; } - return buildQueryFields(data); + const data = groupArrayByKeyPath(flattenToKeyPathArray(input)); + + const keys = Object.keys(data); + if (keys.length === 1) { + return data[keys[0]]; + } + + return data; } -export function buildQueryFields( - data: FieldsBuildInput, -): Record | string | string[] { - switch (true) { - case typeof data === 'string': - return data; - case Array.isArray(data): - return data; - default: - return flattenNestedObject(data as Record); +export function mergeQueryFields( + target: Record | string[], + source: Record | string[], +): Record | string[] { + if (Array.isArray(target)) { + target = groupArrayByKeyPath(target); } + + if (Array.isArray(source)) { + source = groupArrayByKeyPath(source); + } + + const merge = createMerger({ + array: true, + arrayDistinct: true, + }); + + const data = merge({}, target, source); + + const keys = Object.keys(data); + if (keys.length === 1) { + return data[keys[0]]; + } + + return data; } diff --git a/src/parameter/fields/type.ts b/src/parameter/fields/type.ts index 07c8a511..0e352f3f 100644 --- a/src/parameter/fields/type.ts +++ b/src/parameter/fields/type.ts @@ -36,7 +36,8 @@ export type FieldsBuildInput> = }, ] | - FieldWithOperator>[]; + FieldWithOperator>[] | + FieldWithOperator>; // ----------------------------------------------------------- // Parse diff --git a/src/parameter/filters/build.ts b/src/parameter/filters/build.ts index 03638db1..8119cc7b 100644 --- a/src/parameter/filters/build.ts +++ b/src/parameter/filters/build.ts @@ -5,39 +5,41 @@ * view the LICENSE file that was distributed with this source code. */ +import { merge } from 'smob'; import { FiltersBuildInput } from './type'; import { FilterOperator } from './constants'; import { isFilterOperatorConfig } from './utils'; -import { flattenNestedObject, mergeDeep } from '../../utils'; +import { flattenNestedObject } from '../../utils'; -export function buildQueryFiltersForMany( - input: FiltersBuildInput[], -) : Record { - let data : FiltersBuildInput; - for (let i = 0; i < input.length; i++) { - if (data) { - data = mergeDeep(data, input[i]); - } else { - data = input[i]; - } - } - - return buildQueryFilters(data); -} +const OperatorWeight = { + [FilterOperator.NEGATION]: 0, + [FilterOperator.LIKE]: 50, + [FilterOperator.LESS_THAN_EQUAL]: 150, + [FilterOperator.LESS_THAN]: 450, + [FilterOperator.MORE_THAN_EQUAL]: 1350, + [FilterOperator.MORE_THAN]: 4050, + [FilterOperator.IN]: 13105, +}; export function buildQueryFilters( - data: FiltersBuildInput, -) : Record { + data?: FiltersBuildInput, +) : Record { + if (typeof data === 'undefined') { + return {}; + } + return flattenNestedObject(data, { transformer: (input, output, key) => { if (typeof input === 'undefined') { output[key] = null; - return undefined; + return true; } if (isFilterOperatorConfig(input)) { - input.value = transformValue(input.value); + if (typeof input.value === 'undefined') { + input.value = null; + } if (Array.isArray(input.operator)) { // merge operators @@ -47,6 +49,8 @@ export function buildQueryFilters( } output[key] = `${input.operator}${input.value}`; + + return true; } return undefined; @@ -54,20 +58,9 @@ export function buildQueryFilters( }); } -const OperatorWeight = { - [FilterOperator.NEGATION]: 0, - [FilterOperator.LIKE]: 50, - [FilterOperator.LESS_THAN_EQUAL]: 150, - [FilterOperator.LESS_THAN]: 450, - [FilterOperator.MORE_THAN_EQUAL]: 1350, - [FilterOperator.MORE_THAN]: 4050, - [FilterOperator.IN]: 13105, -}; - -function transformValue(value: T) : T | null { - if (typeof value === 'undefined') { - return null; - } - - return value; +export function mergeQueryFilters( + target?: Record, + source?: Record, +) : Record { + return merge({}, target || {}, source || {}); } diff --git a/src/parameter/filters/utils/operator.ts b/src/parameter/filters/utils/operator.ts index 4a9b415f..0813405d 100644 --- a/src/parameter/filters/utils/operator.ts +++ b/src/parameter/filters/utils/operator.ts @@ -68,7 +68,7 @@ export function determineFilterOperatorLabelsByValue(input: string) : { } export function isFilterOperatorConfig(data: unknown) : data is FilterOperatorConfig { - if (typeof data !== 'object') { + if (typeof data !== 'object' || data === null) { return false; } diff --git a/src/parameter/pagination/build.ts b/src/parameter/pagination/build.ts index 20661a0c..439dae5b 100644 --- a/src/parameter/pagination/build.ts +++ b/src/parameter/pagination/build.ts @@ -5,22 +5,12 @@ * view the LICENSE file that was distributed with this source code. */ +import { merge } from 'smob'; import { PaginationBuildInput } from './type'; -import { mergeDeep } from '../../utils'; -export function buildQueryPaginationForMany( - inputs: PaginationBuildInput[], +export function mergeQueryPagination( + target?: PaginationBuildInput, + source?: PaginationBuildInput, ) : PaginationBuildInput { - const inputSources = Array.isArray(inputs) ? inputs : [inputs]; - - let data : PaginationBuildInput; - for (let i = 0; i < inputSources.length; i++) { - if (data) { - data = mergeDeep(data, inputSources[i]); - } else { - data = inputSources[i]; - } - } - - return data; + return merge({}, target || {}, source || {}); } diff --git a/src/parameter/relations/build.ts b/src/parameter/relations/build.ts index bce1572b..56599214 100644 --- a/src/parameter/relations/build.ts +++ b/src/parameter/relations/build.ts @@ -5,27 +5,23 @@ * view the LICENSE file that was distributed with this source code. */ +import { mergeArrays } from 'smob'; import { RelationsBuildInput } from './type'; -import { flattenNestedObject, mergeDeep } from '../../utils'; +import { flattenToKeyPathArray } from '../../utils'; -export function buildQueryRelationsForMany( - input: RelationsBuildInput[], +export function buildQueryRelations( + input?: RelationsBuildInput, ) : string[] { - let data : RelationsBuildInput; - for (let i = 0; i < input.length; i++) { - if (data) { - data = mergeDeep(data, input[i]); - } else { - data = input[i]; - } + if (typeof input === 'undefined') { + return input; } - return buildQueryRelations(data); + return flattenToKeyPathArray(input); } -export function buildQueryRelations(data: RelationsBuildInput): string[] { - const properties: Record = flattenNestedObject(data); - const keys: string[] = Object.keys(properties); - - return Array.from(new Set(keys)); +export function mergeQueryRelations( + target?: string[], + source?: string[], +) : string[] { + return mergeArrays(target || [], source || [], true); } diff --git a/src/parameter/relations/type.ts b/src/parameter/relations/type.ts index 19b88f8d..3ec49f7a 100644 --- a/src/parameter/relations/type.ts +++ b/src/parameter/relations/type.ts @@ -12,8 +12,10 @@ import { Flatten, NestedResourceKeys, OnlyObject } from '../../type'; // ----------------------------------------------------------- export type RelationsBuildInput> = { - [K in keyof T]?: T[K] extends OnlyObject ? RelationsBuildInput> | boolean : never -}; + [K in keyof T]?: Flatten extends OnlyObject ? + RelationsBuildInput> | boolean : + never +} | NestedResourceKeys[]; // ----------------------------------------------------------- // Parse diff --git a/src/parameter/sort/build.ts b/src/parameter/sort/build.ts index d86e4c3f..0c9aa36f 100644 --- a/src/parameter/sort/build.ts +++ b/src/parameter/sort/build.ts @@ -5,38 +5,46 @@ * view the LICENSE file that was distributed with this source code. */ -import { SortBuildInput } from './type'; -import { flattenNestedObject, mergeDeep } from '../../utils'; +import { mergeArrays } from 'smob'; +import { SortBuildInput, SortDirection } from './type'; +import { flattenToKeyPathArray } from '../../utils'; -export function buildQuerySortForMany(inputs: SortBuildInput[]) { - let data: SortBuildInput; +export function buildQuerySort(data?: SortBuildInput) { + if (typeof data === 'undefined') { + return []; + } + + if (typeof data === 'string') { + return [data]; + } - for (let i = 0; i < inputs.length; i++) { - if (data) { - const current = inputs[i]; + return flattenToKeyPathArray(data, { + transformer: ((input, output, path) => { if ( - typeof data === 'string' || - typeof current === 'string' + typeof input === 'string' && + path && + ( + input === SortDirection.ASC || + input === SortDirection.DESC + ) ) { - data = inputs[i]; - } else { - data = mergeDeep(data, current); + if (input === SortDirection.DESC) { + output.push(`-${path}`); + } else { + output.push(path); + } + + return true; } - } else { - data = inputs[i]; - } - } - return buildQuerySort(data); + return undefined; + }), + }); } -export function buildQuerySort(data: SortBuildInput) { - switch (true) { - case typeof data === 'string': - return data; - case Array.isArray(data): - return data; - default: - return flattenNestedObject(data as Record); - } +export function mergeQuerySort( + target?: string[], + source?: string[], +) { + return mergeArrays(target || [], source || [], true); } diff --git a/src/parameter/sort/type.ts b/src/parameter/sort/type.ts index 22588adc..b182480e 100644 --- a/src/parameter/sort/type.ts +++ b/src/parameter/sort/type.ts @@ -40,7 +40,8 @@ export type SortBuildInput> = }, ] | - SortWithOperator>[]; + SortWithOperator>[] | + SortWithOperator>; // ----------------------------------------------------------- // Parse diff --git a/src/type.ts b/src/type.ts index e10c0dc0..0b506b78 100644 --- a/src/type.ts +++ b/src/type.ts @@ -25,8 +25,8 @@ export type NestedKeys> = }[keyof T & (string | number)]; export type NestedResourceKeys> = - {[Key in keyof T & (string | number)]: T[Key] extends Record - ? Key | `${Key}.${NestedKeys}` + {[Key in keyof T & (string | number)]: Flatten extends Record + ? Key | `${Key}.${NestedResourceKeys>}` : never }[keyof T & (string | number)]; diff --git a/src/utils/array.ts b/src/utils/array.ts index 0d18067e..a3b6d2df 100644 --- a/src/utils/array.ts +++ b/src/utils/array.ts @@ -15,34 +15,64 @@ export function buildKeyPath(key: string, prefix?: string) { return key; } +type Options = { + transformer?: ( + input: unknown, + output: string[], + prefix?: string + ) => boolean | undefined +}; + export function flattenToKeyPathArray( - items: unknown, + input: unknown, + options?: Options, prefix?: string, ): string[] { + options = options || {}; + const output: string[] = []; - if (Array.isArray(items)) { - for (let i = 0; i < items.length; i++) { - if (Array.isArray(items[i])) { - for (let j = 0; j < items[i].length; j++) { - const key = buildKeyPath(items[i][j], prefix); + if (options.transformer) { + const result = options.transformer(input, output, prefix); + if (typeof result !== 'undefined' && !!result) { + return output; + } + } + + if (Array.isArray(input)) { + for (let i = 0; i < input.length; i++) { + if (options.transformer) { + const result = options.transformer(input[i], output, prefix); + if (typeof result !== 'undefined' && !!result) { + return output; + } + } + + if (Array.isArray(input[i])) { + for (let j = 0; j < input[i].length; j++) { + const key = buildKeyPath(input[i][j], prefix); output.push(key); } continue; } - if (typeof items[i] === 'string') { - output.push(buildKeyPath(items[i], prefix)); + if (typeof input[i] === 'string') { + output.push(buildKeyPath(input[i], prefix)); continue; } - if (typeof items[i] === 'object') { - const keys = Object.keys(items[i]); + if (typeof input[i] === 'object') { + const keys = Object.keys(input[i]); for (let j = 0; j < keys.length; j++) { const value = buildKeyPath(keys[j] as string, prefix); - output.push(...flattenToKeyPathArray(items[i][keys[j]], value)); + const data = flattenToKeyPathArray(input[i][keys[j]], options, value); + if (data.length === 0) { + output.push(value); + } else { + output.push(...data); + } } } } @@ -51,14 +81,30 @@ export function flattenToKeyPathArray( } if ( - typeof items === 'object' && - items + typeof input === 'object' && + input !== null ) { - const keys = Object.keys(items); + const keys = Object.keys(input); for (let i = 0; i < keys.length; i++) { const value = buildKeyPath(keys[i], prefix); - output.push(...flattenToKeyPathArray((items as Record)[keys[i]], value)); + const data = flattenToKeyPathArray((input as Record)[keys[i]], options, value); + if (data.length === 0) { + output.push(value); + } else { + output.push(...data); + } } + + return output; + } + + if ( + typeof input === 'string' + ) { + const value = buildKeyPath(input, prefix); + output.push(value); + + return output; } return output; diff --git a/src/utils/index.ts b/src/utils/index.ts index 199a43c6..29557d19 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,7 +9,6 @@ export * from './array'; export * from './mapping'; export * from './field'; export * from './relation'; -export * from './merge-deep'; export * from './object'; export * from './simple'; export * from './type'; diff --git a/src/utils/merge-deep.ts b/src/utils/merge-deep.ts deleted file mode 100644 index 395a2522..00000000 --- a/src/utils/merge-deep.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2021-2021. - * Author Peter Placzek (tada5hi) - * For the full copyright and license information, - * view the LICENSE file that was distributed with this source code. - */ - -function isObject(item: unknown) : item is object { - return (item && typeof item === 'object' && !Array.isArray(item)); -} - -/** - * Deep merge two objects. - * @param target - * @param sources - */ -export function mergeDeep< - A extends Record, - B extends Record, ->(target: A, ...sources: B[]) : A & B { - if (!sources.length) return target as A & B; - const source = sources.shift(); - - if ( - isObject(target) && - isObject(source) - ) { - const keys = Object.keys(source); - for (let i = 0; i < keys.length; i++) { - const key : string = keys[i]; - - if (isObject(source[key])) { - if (!target[key]) Object.assign(target, { [key]: {} }); - mergeDeep(target[key], source[key]); - } else { - Object.assign(target, { [key]: source[key] }); - } - } - } - - return mergeDeep(target, ...sources); -} diff --git a/src/utils/object.ts b/src/utils/object.ts index 3de25cb0..0e917b49 100644 --- a/src/utils/object.ts +++ b/src/utils/object.ts @@ -16,7 +16,7 @@ type Options = { input: unknown, output: Record, key: string - ) => Record | undefined + ) => boolean | undefined }; export function flattenNestedObject( @@ -31,8 +31,8 @@ export function flattenNestedObject( if (options.transformer) { const result = options.transformer(data, output, prefixParts.join('.')); - if (typeof result !== 'undefined') { - return { ...output, ...flattenNestedObject(result, options, prefixParts) }; + if (typeof result !== 'undefined' && !!result) { + return output; } } @@ -42,11 +42,9 @@ export function flattenNestedObject( if (options.transformer) { const result = options.transformer(data[key], output, [...prefixParts, key].join('.')); - if (typeof result !== 'undefined') { - output = { ...output, ...flattenNestedObject(result, options, prefixParts) }; + if (typeof result !== 'undefined' && !!result) { + continue; } - - continue; } if ( diff --git a/test/unit/build.spec.ts b/test/unit/build.spec.ts index bc821344..1d3fcd52 100644 --- a/test/unit/build.spec.ts +++ b/test/unit/build.spec.ts @@ -6,30 +6,38 @@ */ import { - FilterOperator, Parameter, SortDirection, URLParameter, buildQuery, + FilterOperator, Parameter, SortDirection, URLParameter, buildQuery, DEFAULT_ID, } from '../../src'; import { buildURLQueryString } from '../../src/utils'; describe('src/build.ts', () => { - class ChildEntity { + type GrandChild = { + id: string, + + name: string + } + + type ChildEntity = { id: number; name: string; age: number; + + child: GrandChild } type Entity = { id: number, name: string, child: ChildEntity, - siblings: Entity[] + siblings: ChildEntity[] }; it('should format filter record', () => { let record = buildQuery({ filter: { - id: 1, + id: 1 }, }); expect(record).toEqual(buildURLQueryString({ [URLParameter.FILTERS]: { id: 1 } })); @@ -183,13 +191,15 @@ describe('src/build.ts', () => { expect(record).toEqual(buildURLQueryString({ fields: ['+id', 'name'] })); record = buildQuery({ - fields: { - default: ['id'], - child: ['id', 'name'], - }, + fields: [ + ['id'], + { + child: ['id', 'name'], + } + ] }); - expect(record).toEqual(buildURLQueryString({ fields: { default: ['id'], child: ['id', 'name'] } })); + expect(record).toEqual(buildURLQueryString({ fields: { [DEFAULT_ID]: ['id'], child: ['id', 'name'] } })); }); it('should format sort record', () => { @@ -199,7 +209,7 @@ describe('src/build.ts', () => { }, }); - expect(record).toEqual(buildURLQueryString({ [URLParameter.SORT]: { id: 'DESC' } })); + expect(record).toEqual(buildURLQueryString({ [URLParameter.SORT]: '-id' })); record = buildQuery({ sort: '-id', @@ -221,7 +231,7 @@ describe('src/build.ts', () => { }, }); - expect(record).toEqual(buildURLQueryString({ [URLParameter.SORT]: { 'child.id': 'DESC' } })); + expect(record).toEqual(buildURLQueryString({ [URLParameter.SORT]: '-child.id' })); }); it('should format page record', () => { @@ -275,9 +285,7 @@ describe('src/build.ts', () => { expect(record).toEqual(buildURLQueryString({ [URLParameter.PAGINATION]: { limit: 10, offset: 0 } })); record = buildQuery({ - [Parameter.RELATIONS]: { - child: true, - }, + [Parameter.RELATIONS]: ['child', 'child.child'], [URLParameter.RELATIONS]: { siblings: { child: true, @@ -285,6 +293,6 @@ describe('src/build.ts', () => { }, }); - expect(record).toEqual(buildURLQueryString({ [URLParameter.RELATIONS]: ['child', 'siblings.child'] })); + expect(record).toEqual(buildURLQueryString({ [URLParameter.RELATIONS]: ['child', 'child.child', 'siblings.child'] })); }); });