refactor parseQueryString

This commit is contained in:
Keith Grant 2019-09-18 09:19:03 -07:00
parent b2b475d1a6
commit eb0c4fd4d4
2 changed files with 321 additions and 253 deletions

View File

@ -1,10 +1,226 @@
/**
* Returns queryset config with defaults, if needed
* @param {string} namespace for appending to url querystring
* @param {object} default params that are not handled with search (page, page_size and order_by)
* @param {array} params that are number fields
* @return {object} query param object
*/
export function getQSConfig(
namespace,
defaultParams = { page: 1, page_size: 5, order_by: 'name' },
integerFields = ['page', 'page_size'],
dateFields = ['modified', 'created']
) {
if (!namespace) {
throw new Error('a QS namespace is required');
}
return {
namespace,
defaultParams,
integerFields,
dateFields,
};
}
/**
* Convert url query string to query param object
* @param {object} qs config object (used for getting defaults, current query params etc.)
* @param {string} url query string
* @return {object} query param object
*/
// TODO: rename to parseNamespacedQueryString?
export function parseQueryString(config, queryString) {
if (!queryString) {
return config.defaultParams;
}
const params = stringToObject(config, queryString);
return addDefaultsToObject(config, params);
}
/**
* Convert query param object to url query string
* Used to encode params for interacting with the api
* @param {object} qs config object for namespacing params, filtering defaults
* @param {object} query param object
* @return {string} url query string
*/
export const encodeQueryString = params => {
if (!params) return '';
return Object.keys(params)
.sort()
.filter(key => params[key] !== null)
.map(key => [key, params[key]])
.map(([key, value]) => {
// if value is array, should return more than one key value pair
if (Array.isArray(value)) {
return value
.map(val => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`)
.join('&');
}
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join('&');
};
/**
* Convert query param object to url query string, adding namespace and removing defaults
* Used to put into url bar after ui route
* @param {object} qs config object for namespacing params, filtering defaults
* @param {object} query param object
* @return {string} url query string
*/
export const encodeNonDefaultQueryString = (config, params) => {
if (!params) return '';
const namespacedParams = namespaceParams(config.namespace, params);
const namespacedDefaults = namespaceParams(
config.namespace,
config.defaultParams
);
const namespacedDefaultKeys = Object.keys(namespacedDefaults);
const namespacedParamsWithoutDefaultsKeys = Object.keys(
namespacedParams
).filter(
key =>
namespacedDefaultKeys.indexOf(key) === -1 ||
!paramValueIsEqual(namespacedParams[key], namespacedDefaults[key])
);
return namespacedParamsWithoutDefaultsKeys
.sort()
.filter(key => namespacedParams[key] !== null)
.map(key => {
return [key, namespacedParams[key]];
})
.map(([key, value]) => {
// if value is array, should return more than one key value pair
if (Array.isArray(value)) {
return value
.map(val => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`)
.join('&');
}
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join('&');
};
/**
* Merges existing params of search string with new ones and returns the updated list of params
* @param {object} qs config object (used for getting defaults, current query params etc.)
* @param {object} object with params from existing search
* @param {object} object with new params to add
* @return {object} query param object
*/
export function addParams(config, oldParams, paramsToAdd) {
const namespacedOldParams = namespaceParams(config.namespace, oldParams);
const namespacedParamsToAdd = namespaceParams(config.namespace, paramsToAdd);
const namespacedDefaultParams = namespaceParams(
config.namespace,
config.defaultParams
);
const namespacedOldParamsNotDefaults = getNonDefaultParams(
namespacedOldParams,
namespacedDefaultParams
);
const namespacedMergedParams = getMergedParams(
namespacedOldParamsNotDefaults,
namespacedParamsToAdd
);
// return updated params.
// If newParams includes updates to the defaults, they will be replaced,
// not concatenated.
return denamespaceParams(config.namespace, {
...getDefaultParams(namespacedOldParams, namespacedDefaultParams),
...namespacedMergedParams,
...getRemainingNewParams(namespacedMergedParams, namespacedParamsToAdd),
});
}
/**
* Removes params from the search string and returns the updated list of params
* @param {object} qs config object (used for getting defaults, current query params etc.)
* @param {object} object with params from existing search
* @param {object} object with new params to remove
* @return {object} query param object
*/
export function removeParams(config, oldParams, paramsToRemove) {
const paramsEntries = [];
Object.entries(oldParams).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(val => {
paramsEntries.push([key, val]);
});
} else {
paramsEntries.push([key, value]);
}
});
const paramsToRemoveEntries = Object.entries(paramsToRemove);
const remainingEntries = paramsEntries.filter(
([key, value]) =>
paramsToRemoveEntries.filter(
([newKey, newValue]) => key === newKey && value === newValue
).length === 0
);
const remainingObject = arrayToObject(remainingEntries);
const defaultEntriesLeftover = Object.entries(config.defaultParams).filter(
([key]) => !remainingObject[key]
);
const finalParamsEntries = remainingEntries;
defaultEntriesLeftover.forEach(value => {
finalParamsEntries.push(value);
});
return arrayToObject(finalParamsEntries);
}
const stringToObject = (config, qs) => {
const params = {};
qs.replace(/^\?/, '')
.split('&')
.map(s => s.split('='))
.forEach(([nsKey, rawValue]) => {
if (!nsKey || !nsKey.startsWith(`${config.namespace}.`)) {
return;
}
const key = decodeURIComponent(nsKey.substr(config.namespace.length + 1));
const value = parseValue(config, key, rawValue);
if (!params[key]) {
params[key] = value;
} else if (Array.isArray(params[key])) {
params[key].push(value);
} else {
params[key] = [params[key], value];
}
});
return params;
};
export { stringToObject as _stringToObject };
function addDefaultsToObject(config, params) {
return {
...config.defaultParams,
...params,
};
}
export { addDefaultsToObject as _addDefaultsToObject };
function parseValue(config, key, rawValue) {
if (config.integerFields && config.integerFields.some(v => v === key)) {
return parseInt(rawValue, 10);
}
// TODO: parse date fields
return decodeURIComponent(rawValue);
}
/**
* helper function used to convert from
* Object.entries format ([ [ key, value ], ... ]) to object
* @param {array} array in the format [ [ key, value ], ...]
* @return {object} object in the forms { key: value, ... }
*/
const toObject = entriesArr =>
const arrayToObject = entriesArr =>
entriesArr.reduce((acc, [key, value]) => {
if (acc[key] && Array.isArray(acc[key])) {
acc[key].push(value);
@ -83,176 +299,6 @@ const paramValueIsEqual = (one, two) => {
return isEqual;
};
/**
* Convert query param object to url query string
* Used to encode params for interacting with the api
* @param {object} qs config object for namespacing params, filtering defaults
* @param {object} query param object
* @return {string} url query string
*/
export const encodeQueryString = params => {
if (!params) return '';
return Object.keys(params)
.sort()
.filter(key => params[key] !== null)
.map(key => [key, params[key]])
.map(([key, value]) => {
// if value is array, should return more than one key value pair
if (Array.isArray(value)) {
return value
.map(val => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`)
.join('&');
}
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join('&');
};
/**
* Convert query param object to url query string, adding namespace and removing defaults
* Used to put into url bar after ui route
* @param {object} qs config object for namespacing params, filtering defaults
* @param {object} query param object
* @return {string} url query string
*/
export const encodeNonDefaultQueryString = (config, params) => {
if (!params) return '';
const namespacedParams = namespaceParams(config.namespace, params);
const namespacedDefaults = namespaceParams(
config.namespace,
config.defaultParams
);
const namespacedDefaultKeys = Object.keys(namespacedDefaults);
const namespacedParamsWithoutDefaultsKeys = Object.keys(
namespacedParams
).filter(
key =>
namespacedDefaultKeys.indexOf(key) === -1 ||
!paramValueIsEqual(namespacedParams[key], namespacedDefaults[key])
);
return namespacedParamsWithoutDefaultsKeys
.sort()
.filter(key => namespacedParams[key] !== null)
.map(key => {
return [key, namespacedParams[key]];
})
.map(([key, value]) => {
// if value is array, should return more than one key value pair
if (Array.isArray(value)) {
return value
.map(val => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`)
.join('&');
}
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join('&');
};
/**
* Returns queryset config with defaults, if needed
* @param {string} namespace for appending to url querystring
* @param {object} default params that are not handled with search (page, page_size and order_by)
* @param {array} params that are number fields
* @return {object} query param object
*/
export function getQSConfig(
namespace,
defaultParams = { page: 1, page_size: 5, order_by: 'name' },
integerFields = ['page', 'page_size'],
dateFields = ['modified', 'created']
) {
if (!namespace) {
throw new Error('a QS namespace is required');
}
return {
namespace,
defaultParams,
integerFields,
dateFields,
};
}
/**
* Convert url query string to query param object
* @param {object} qs config object (used for getting defaults, current query params etc.)
* @param {string} url query string
* @return {object} query param object
*/
export function parseQueryString(config, queryString) {
if (!queryString) return config.defaultParams;
const namespacedIntegerFields = config.integerFields.map(f =>
config.namespace ? `${config.namespace}.${f}` : f
);
const keyValuePairs = queryString
.replace(/^\?/, '')
.split('&')
.map(s => s.split('='))
.map(([key, value]) => {
if (namespacedIntegerFields.includes(key)) {
return [decodeURIComponent(key), parseInt(value, 10)];
}
return [decodeURIComponent(key), decodeURIComponent(value)];
});
const keyValueObject = toObject(keyValuePairs);
// needs to return array for duplicate keys
// ie [[k1, v1], [k1, v2], [k2, v3]]
// -> [[k1, [v1, v2]], [k2, v3]]
const dedupedKeyValuePairs = Object.keys(keyValueObject).map(key => {
const values = keyValuePairs.filter(([k]) => k === key).map(([, v]) => v);
if (values.length === 1) {
return [key, values[0]];
}
return [key, values];
});
const parsed = Object.assign(
...dedupedKeyValuePairs.map(([k, v]) => ({
[k]: v,
}))
);
const namespacedParams = {};
Object.keys(parsed).forEach(field => {
if (namespaceMatches(config.namespace, field)) {
let fieldname = field;
if (config.namespace) {
fieldname = field.substr(config.namespace.length + 1);
}
namespacedParams[fieldname] = parsed[field];
}
});
const namespacedDefaults = namespaceParams(
config.namespace,
config.defaultParams
);
Object.keys(namespacedDefaults)
.filter(key => Object.keys(parsed).indexOf(key) === -1)
.forEach(field => {
if (namespaceMatches(config.namespace, field)) {
let fieldname = field;
if (config.namespace) {
fieldname = field.substr(config.namespace.length + 1);
}
namespacedParams[fieldname] = namespacedDefaults[field];
}
});
return namespacedParams;
}
/**
* helper function to get params that are defaults
* @param {object} namespaced params object
@ -260,7 +306,7 @@ export function parseQueryString(config, queryString) {
* @return {object} namespaced params object of only defaults
*/
const getDefaultParams = (params, defaults) =>
toObject(
arrayToObject(
Object.keys(params)
.filter(key => Object.keys(defaults).indexOf(key) > -1)
.map(key => [key, params[key]])
@ -273,7 +319,7 @@ const getDefaultParams = (params, defaults) =>
* @return {object} namespaced params object of non-defaults
*/
const getNonDefaultParams = (params, defaults) =>
toObject(
arrayToObject(
Object.keys(params)
.filter(key => Object.keys(defaults).indexOf(key) === -1)
.map(key => [key, params[key]])
@ -286,7 +332,7 @@ const getNonDefaultParams = (params, defaults) =>
* @return {object} merged namespaced params object
*/
const getMergedParams = (oldParams, newParams) =>
toObject(
arrayToObject(
Object.keys(oldParams).map(key => {
let oldVal = oldParams[key];
const newVal = newParams[key];
@ -308,78 +354,8 @@ const getMergedParams = (oldParams, newParams) =>
* @return {object} remaining new namespaced params object
*/
const getRemainingNewParams = (mergedParams, newParams) =>
toObject(
arrayToObject(
Object.keys(newParams)
.filter(key => Object.keys(mergedParams).indexOf(key) === -1)
.map(key => [key, newParams[key]])
);
/**
* Merges existing params of search string with new ones and returns the updated list of params
* @param {object} qs config object (used for getting defaults, current query params etc.)
* @param {object} object with params from existing search
* @param {object} object with new params to add
* @return {object} query param object
*/
export function addParams(config, oldParams, paramsToAdd) {
const namespacedOldParams = namespaceParams(config.namespace, oldParams);
const namespacedParamsToAdd = namespaceParams(config.namespace, paramsToAdd);
const namespacedDefaultParams = namespaceParams(
config.namespace,
config.defaultParams
);
const namespacedOldParamsNotDefaults = getNonDefaultParams(
namespacedOldParams,
namespacedDefaultParams
);
const namespacedMergedParams = getMergedParams(
namespacedOldParamsNotDefaults,
namespacedParamsToAdd
);
// return updated params.
// If newParams includes updates to the defaults, they will be replaced,
// not concatenated.
return denamespaceParams(config.namespace, {
...getDefaultParams(namespacedOldParams, namespacedDefaultParams),
...namespacedMergedParams,
...getRemainingNewParams(namespacedMergedParams, namespacedParamsToAdd),
});
}
/**
* Removes params from the search string and returns the updated list of params
* @param {object} qs config object (used for getting defaults, current query params etc.)
* @param {object} object with params from existing search
* @param {object} object with new params to remove
* @return {object} query param object
*/
export function removeParams(config, oldParams, paramsToRemove) {
const paramsEntries = [];
Object.entries(oldParams).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(val => {
paramsEntries.push([key, val]);
});
} else {
paramsEntries.push([key, value]);
}
});
const paramsToRemoveEntries = Object.entries(paramsToRemove);
const remainingEntries = paramsEntries.filter(
([key, value]) =>
paramsToRemoveEntries.filter(
([newKey, newValue]) => key === newKey && value === newValue
).length === 0
);
const remainingObject = toObject(remainingEntries);
const defaultEntriesLeftover = Object.entries(config.defaultParams).filter(
([key]) => !remainingObject[key]
);
const finalParamsEntries = remainingEntries;
defaultEntriesLeftover.forEach(value => {
finalParamsEntries.push(value);
});
return toObject(finalParamsEntries);
}

View File

@ -5,6 +5,9 @@ import {
getQSConfig,
addParams,
removeParams,
_paramStringToArray,
_stringToObject,
_addDefaultsToObject,
} from './qs';
describe('qs (qs.js)', () => {
@ -81,6 +84,7 @@ describe('qs (qs.js)', () => {
dateFields: ['modified', 'created'],
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
integerFields: ['page', 'page_size'],
dateFields: ['modified', 'created'],
});
});
@ -98,6 +102,7 @@ describe('qs (qs.js)', () => {
dateFields: ['modified', 'created'],
defaultParams: { page: 1, page_size: 15 },
integerFields: ['page', 'page_size'],
dateFields: ['modified', 'created'],
});
});
});
@ -105,11 +110,11 @@ describe('qs (qs.js)', () => {
describe('parseQueryString', () => {
test('should get query params', () => {
const config = {
namespace: null,
namespace: 'item',
defaultParams: { page: 1, page_size: 15 },
integerFields: ['page', 'page_size'],
};
const query = '?baz=bar&page=3';
const query = '?item.baz=bar&item.page=3';
expect(parseQueryString(config, query)).toEqual({
baz: 'bar',
page: 3,
@ -132,11 +137,11 @@ describe('qs (qs.js)', () => {
test('should get query params with correct integer fields', () => {
const config = {
namespace: null,
namespace: 'item',
defaultParams: {},
integerFields: ['page', 'foo'],
};
const query = '?foo=4&bar=5';
const query = '?item.foo=4&item.bar=5';
expect(parseQueryString(config, query)).toEqual({
foo: 4,
bar: '5',
@ -145,16 +150,28 @@ describe('qs (qs.js)', () => {
test('should decode parsed params', () => {
const config = {
namespace: null,
namespace: 'item',
defaultParams: {},
integerFields: ['page'],
};
const query = '?foo=bar%20baz';
const query = '?item.foo=bar%20baz';
expect(parseQueryString(config, query)).toEqual({
foo: 'bar baz',
});
});
test('should decode param keys', () => {
const config = {
namespace: 'item',
defaultParams: {},
integerFields: ['page'],
};
const query = '?item.foo%20bar=baz';
expect(parseQueryString(config, query)).toEqual({
'foo bar': 'baz',
});
});
test('should get namespaced query params', () => {
const config = {
namespace: 'inventory',
@ -199,11 +216,11 @@ describe('qs (qs.js)', () => {
test('should add duplicate non-default params as array', () => {
const config = {
namespace: null,
namespace: 'item',
defaultParams: { page: 1, page_size: 15 },
integerFields: ['page', 'page_size'],
};
const query = '?baz=bar&baz=boo&page=3';
const query = '?item.baz=bar&item.baz=boo&item.page=3';
expect(parseQueryString(config, query)).toEqual({
baz: ['bar', 'boo'],
page: 3,
@ -550,4 +567,79 @@ describe('qs (qs.js)', () => {
});
});
});
describe('_stringToObject', () => {
it('should convert to object', () => {
const config = { namespace: 'unit' };
expect(_stringToObject(config, '?unit.foo=bar&unit.baz=bam')).toEqual({
foo: 'bar',
baz: 'bam',
});
});
it('should convert duplicated keys to array', () => {
const config = { namespace: 'unit' };
expect(_stringToObject(config, '?unit.foo=bar&unit.foo=bam')).toEqual({
foo: ['bar', 'bam'],
});
});
it('should omit keys from other namespaces', () => {
const config = { namespace: 'unit' };
expect(
_stringToObject(config, '?unit.foo=bar&other.bar=bam&one=two')
).toEqual({
foo: 'bar',
});
});
it('should convert numbers to correct type', () => {
const config = {
namespace: 'unit',
integerFields: ['page'],
};
expect(_stringToObject(config, '?unit.page=3')).toEqual({
page: 3,
});
});
});
describe('_addDefaultsToObject', () => {
it('should add missing default values', () => {
const config = {
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
}
expect(_addDefaultsToObject(config, {})).toEqual({
page: 1,
page_size: 5,
order_by: 'name',
});
});
it('should not override existing params', () => {
const config = {
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
}
const params = {
page: 2,
order_by: 'date_created',
}
expect(_addDefaultsToObject(config, params)).toEqual({
page: 2,
page_size: 5,
order_by: 'date_created',
});
});
it('should handle missing defaultParams', () => {
const params = {
page: 2,
order_by: 'date_created',
}
expect(_addDefaultsToObject({}, params)).toEqual({
page: 2,
order_by: 'date_created',
});
})
})
});