From 3d974094c4dec33d5d32d4f627107d9b27363c42 Mon Sep 17 00:00:00 2001 From: gbendikar Date: Tue, 4 Mar 2025 17:33:52 +0000 Subject: [PATCH 1/5] Add support for numeric comparison predicates --- README.md | 36 +++++++++++++++++++++++- src/modules/search.js | 18 ++++++++++++ src/modules/search.test.js | 56 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 01fd4c9..0911ef9 100644 --- a/README.md +++ b/README.md @@ -1190,6 +1190,16 @@ s.data('level1', 'level2'); // 'data.level1.level2' ``` +##### compareToField(field) + +Build value as field to facilitate field to field comparison. Can only be used in context of a value in a search dsl +predicate + +```js +s.greaterThan('field1', s.compareToField('field2')); +// greaterThan: {field: field1, value: {field: field2}} +``` + ##### contains(field, value) Build a criterion matching `MultiSelectList` and `Collection` fields which contains the provided value. @@ -1232,6 +1242,26 @@ Dynamic field names must be prefixed with `data.`. Build a criterion matching `YesOrNo` fields that are either `true` or `false`. Dynamic field names must be prefixed with `data.`. +##### greaterThan(field, value) + +Build a criterion matching on field greater than provided value. +Dynamic field names must be prefixed with `data.`. + +##### greaterThanOrEquals(field, value) + +Build a criterion matching on field greater than or equals provided value. +Dynamic field names must be prefixed with `data.`. + +##### lessThan(field, value) + +Build a criterion matching on field less than provided value. +Dynamic field names must be prefixed with `data.`. + +##### lessThanOrEquals(field, value) + +Build a criterion matching on field less than or equals provided value. +Dynamic field names must be prefixed with `data.`. + ##### sort(...instructions) Build the top-level `sort` object by combining one or many sorting instructions. @@ -1273,7 +1303,11 @@ const query = s.query( s.equalsAny(s.data('field2'), ['VALUE_1', 'VALUE_2']), s.equalsAnyIgnoreCase(s.data('field2'), ['value_1', 'value_2']), s.hasValue(s.data('field3'), true), - s.is(s.data('yesOrNoField'), true), + s.is(s.data('yesOrNoField'), true), + s.greaterThan(s.data('numberField1'), 10), + s.greaterThanOrEquals(s.data('numberField2'), 10), + s.lessThan(s.data('numberField3'), 10), + s.lessThanOrEquals(s.data('numberField4'), 10), ) ); diff --git a/src/modules/search.js b/src/modules/search.js index 01fbaa8..0986afa 100644 --- a/src/modules/search.js +++ b/src/modules/search.js @@ -33,6 +33,10 @@ const hasValue = matcher('hasValue'); const dateRange = matcher('dateRange'); const contains = matcher('contains'); const is = matcher('is'); +const greaterThan = matcher('greaterThan'); +const greaterThanOrEquals = matcher('greaterThanOrEquals'); +const lessThan = matcher('lessThan'); +const lessThanOrEquals = matcher('lessThanOrEquals'); const and = (...criteria) => ({ and: criteria, @@ -83,9 +87,19 @@ const collectionItem = (field, value) => ({ field, value }); +/** + * Build value as field in field to field comparison. Can only be used in context of value for a given querydsl. + * @param field - field name to compare with + * @returns {{field}} object representing field to be compared + */ +const compareToField = (field) => ({ + field, +}); + export const searchDsl = Object.freeze({ not, and, + compareToField, data, equals, equalsIgnoreCase, @@ -95,6 +109,10 @@ export const searchDsl = Object.freeze({ dateRange, contains, is, + greaterThan, + greaterThanOrEquals, + lessThan, + lessThanOrEquals, page, query, or, diff --git a/src/modules/search.test.js b/src/modules/search.test.js index 0ab1c18..d50ec89 100644 --- a/src/modules/search.test.js +++ b/src/modules/search.test.js @@ -3,8 +3,8 @@ import {search, searchDsl as dsl} from './search'; describe('search', () => { test('should perform search', async () => { const resData = { - total: { count: 2, pages: 1 }, - page: { index: 1, size: 25 }, + total: {count: 2, pages: 1}, + page: {index: 1, size: 25}, results: [ {}, {} ], @@ -219,4 +219,56 @@ describe('searchDsl', () => { expect(dsl.data('level1', 'level2')).toEqual('data.level1.level2'); expect(dsl.data(...['level1', 'level2'])).toEqual('data.level1.level2'); }) + + test('should return `greaterThan` matcher', () => { + const greaterThan = dsl.greaterThan('field1', 1); + expect(greaterThan).toEqual({ + greaterThan: { + field: 'field1', + value: 1 + } + }); + }); + + test('should return `greaterThanOrEquals` matcher', () => { + const greaterThanOrEquals = dsl.greaterThanOrEquals('field1', 1); + expect(greaterThanOrEquals).toEqual({ + greaterThanOrEquals: { + field: 'field1', + value: 1 + } + }); + }); + + test('should return `lessThan` matcher', () => { + const lessThan = dsl.lessThan('field1', 1); + expect(lessThan).toEqual({ + lessThan: { + field: 'field1', + value: 1 + } + }); + }); + + test('should return `lessThanOrEquals` matcher', () => { + const lessThanOrEquals = dsl.lessThanOrEquals('field1', 1); + expect(lessThanOrEquals).toEqual({ + lessThanOrEquals: { + field: 'field1', + value: 1 + } + }); + }); + + test('should return `lessThanOrEquals` matcher for field to field comparison', () => { + const lessThanOrEquals = dsl.lessThanOrEquals('field1', dsl.compareToField('field2')); + expect(lessThanOrEquals).toEqual({ + lessThanOrEquals: { + field: 'field1', + value: { + field: 'field2', + } + } + }); + }); }); From 137463d61ca31809c596c8535177b1b26a9c3860 Mon Sep 17 00:00:00 2001 From: gbendikar Date: Thu, 6 Mar 2025 09:29:02 +0000 Subject: [PATCH 2/5] Add support to pass request parameters to http client requests --- src/modules/http-client.js | 10 +++++-- src/modules/http-client.test.js | 53 +++++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/modules/http-client.js b/src/modules/http-client.js index 064772a..6b38945 100644 --- a/src/modules/http-client.js +++ b/src/modules/http-client.js @@ -18,9 +18,15 @@ export const httpClient = (baseUrl, axiosInstance = axios) => (accessTokenProvid put: bodyRequest(axiosInstance.put)(urlBuilder(baseUrl))(accessTokenProvider), }); -const emptyRequest = (axiosFn) => (url) => (accessTokenProvider) => async (relativeUrl) => axiosFn(url(relativeUrl), headers(await authorization(accessTokenProvider))); +const emptyRequest = (axiosFn) => (url) => (accessTokenProvider) => async (relativeUrl, params) => axiosFn(url(relativeUrl), { + ...headers(await authorization(accessTokenProvider)), + params, +}); -const bodyRequest = (axiosFn) => (url) => (accessTokenProvider) => async (relativeUrl, body) => axiosFn(url(relativeUrl), body, headers(await authorization(accessTokenProvider))); +const bodyRequest = (axiosFn) => (url) => (accessTokenProvider) => async (relativeUrl, body, params) => axiosFn(url(relativeUrl), body, { + ...headers(await authorization(accessTokenProvider)), + params, +}); const urlBuilder = (baseUrl) => (relativeUrl) => baseUrl + relativeUrl; diff --git a/src/modules/http-client.test.js b/src/modules/http-client.test.js index 7f2db92..333999d 100644 --- a/src/modules/http-client.test.js +++ b/src/modules/http-client.test.js @@ -17,6 +17,21 @@ describe('httpClient', () => { expect(axios.get).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', {headers: {Authorization: 'Bearer access-token-123'}}); }); + test('should get resource with request parameters', async () => { + const expectedResp = {data: {foo: 'bar'}}; + axios.get.mockResolvedValue(expectedResp); + + const resp = await httpClient(baseUrl)(tokenProviderStub).get('/path/to/resource', {['with-links']: 'true'}); + + expect(resp).toEqual(expectedResp); + expect(axios.get).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', + { + headers: {Authorization: 'Bearer access-token-123'}, + params: {['with-links']: 'true'}, + } + ); + }); + test('should securely post resource', async () => { const body = {foo: 'bar'}; const expectedResp = {status: 201}; @@ -25,7 +40,24 @@ describe('httpClient', () => { const resp = await httpClient(baseUrl)(tokenProviderStub).post('/path/to/resource', body); expect(resp).toEqual(expectedResp); - expect(axios.post).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', body,{headers: {Authorization: 'Bearer access-token-123'}}); + expect(axios.post).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', body, {headers: {Authorization: 'Bearer access-token-123'}}); + }); + + test('should post resource with request parameters', async () => { + const body = {foo: 'bar'}; + const expectedResp = {status: 201}; + axios.post.mockResolvedValue(expectedResp); + + const resp = await httpClient(baseUrl)(tokenProviderStub).post('/path/to/resource', body, {['computed-fields']: ['computedField1']}); + + expect(resp).toEqual(expectedResp); + expect(axios.post).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', + body, + { + headers: {Authorization: 'Bearer access-token-123'}, + params: {['computed-fields']: ['computedField1']}, + } + ); }); test('should securely put resource', async () => { @@ -36,7 +68,24 @@ describe('httpClient', () => { const resp = await httpClient(baseUrl)(tokenProviderStub).put('/path/to/resource', body); expect(resp).toEqual(expectedResp); - expect(axios.put).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', body,{headers: {Authorization: 'Bearer access-token-123'}}); + expect(axios.put).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', body, {headers: {Authorization: 'Bearer access-token-123'}}); + }); + + test('should put resource with request parameters', async () => { + const body = {foo: 'bar'}; + const expectedResp = {status: 200}; + axios.put.mockResolvedValue(expectedResp); + + const resp = await httpClient(baseUrl)(tokenProviderStub).put('/path/to/resource', body, {filter: 'test'}); + + expect(resp).toEqual(expectedResp); + expect(axios.put).toHaveBeenCalledWith('http://data-store:4452/path/to/resource', + body, + { + headers: {Authorization: 'Bearer access-token-123'}, + params: {filter: 'test'}, + } + ); }); test('should accept custom axios instance', async () => { From ca97d8f2e91c3349b12a74e5bf13337714703206 Mon Sep 17 00:00:00 2001 From: gbendikar Date: Thu, 6 Mar 2025 09:30:39 +0000 Subject: [PATCH 3/5] Add utility to build search request parameters --- README.md | 20 ++++++++++- src/modules/search.js | 29 ++++++++++++++-- src/modules/search.test.js | 69 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0911ef9..6e3d50d 100644 --- a/README.md +++ b/README.md @@ -1285,6 +1285,19 @@ Build the top-level `page` object. * `index` is the number of the results page to retrieve and starts at `1` * `size` is the maximum number of results to retrieve per page and is capped at `100` +#### searchParams(additionalProperties) + +Build optional search request parameters + +* `additionalProperties` - additional properties to be passed as request parameters + +```js +s.searchParams() + .withLinks() + .withComputedFields('computedField') + .build(); +``` + #### Example ```javascript @@ -1318,7 +1331,12 @@ const sort = s.sort( const page = s.page(1, 30); -const response = await search(searchClient)('CaseType1')(query)(sort)(page); +const params = s.searchParams() + .withLinks() + .withComputedFields('computedField') + .build(); + +const response = await search(searchClient)('CaseType1')(query)(sort)(page, params); ``` ### Redis Gateway diff --git a/src/modules/search.js b/src/modules/search.js index 0986afa..19379f6 100644 --- a/src/modules/search.js +++ b/src/modules/search.js @@ -2,14 +2,14 @@ * Search */ - export const search = (http) => (caseTypeId) => (query) => (sort) => async (page) => { + export const search = (http) => (caseTypeId) => (query) => (sort) => async (page, params) => { const url = `/case-types/${caseTypeId}/cases/search`; const body = { ...query, ...sort, ...page, }; - const res = await http.post(url, body); + const res = await http.post(url, body, params); return res.data; }; @@ -96,6 +96,30 @@ const compareToField = (field) => ({ field, }); +/** + * Build optional search request parameters + * @param additionalParams - optional additional parameters to be included in the search query parameters + * @returns object with optional parameters to build + */ +const searchParams = (additionalParams) => { + const params = {...additionalParams}; + return { + withLinks: function () { + Object.assign(params, {['with-links']: 'true'}); + return this; + }, + withComputedFields: function (...computedFields) { + if (computedFields?.length > 0) { + Object.assign(params, {['computed-fields']: computedFields.join(',')}); + } + return this; + }, + build: function () { + return params; + }, + } +}; + export const searchDsl = Object.freeze({ not, and, @@ -116,6 +140,7 @@ export const searchDsl = Object.freeze({ page, query, or, + searchParams, sort, sortAsc, sortDesc, diff --git a/src/modules/search.test.js b/src/modules/search.test.js index d50ec89..35b4e62 100644 --- a/src/modules/search.test.js +++ b/src/modules/search.test.js @@ -26,6 +26,35 @@ describe('search', () => { expect(data).toEqual(resData); }); + + test('should perform search with parameters', async () => { + const resData = { + total: {count: 2, pages: 1}, + page: {index: 1, size: 25}, + results: [ + {}, {} + ], + }; + + const httpStub = { + post: (url, body, params) => { + expect(url).toEqual('/case-types/CaseType1/cases/search'); + expect(body).toEqual({ + query: 'criteria', + sort: 'order', + page: 'limit', + }); + expect(params).toEqual({ + filter: 'test', + }); + return Promise.resolve({data: resData}); + }, + }; + + const data = await search(httpStub)('CaseType1')({query: 'criteria'})({sort: 'order'})({page: 'limit'}, {filter: 'test'}); + + expect(data).toEqual(resData); + }); }); describe('searchDsl', () => { @@ -272,3 +301,43 @@ describe('searchDsl', () => { }); }); }); + +describe('searchParams', () => { + test('should build search parameter for links', () => { + const params = dsl.searchParams().withLinks().build(); + expect(params).toEqual({ + ['with-links']: 'true' + }); + }); + + test('should build search parameter for a single computed field', () => { + const params = dsl.searchParams().withComputedFields('computedField1').build(); + expect(params).toEqual({ + ['computed-fields']: 'computedField1', + }); + }); + + test('should build search parameter for multiple computed fields', () => { + const params = dsl.searchParams().withComputedFields('computedField1', 'computedField2').build(); + expect(params).toEqual({ + ['computed-fields']: 'computedField1,computedField2', + }); + }); + + test('should not build search parameter for computed fields when none are provided', () => { + const params = dsl.searchParams().withComputedFields().build(); + expect(params).toEqual({}); + }); + + test('should build search parameters for provided additional parameters', () => { + const params = dsl.searchParams({param1: 'test'}) + .withLinks() + .withComputedFields('computedField1') + .build(); + expect(params).toEqual({ + param1: 'test', + ['with-links']: 'true', + ['computed-fields']: 'computedField1', + }); + }); +}); From b2fe192337b5fa3e663576832b619d8ea2455195 Mon Sep 17 00:00:00 2001 From: gbendikar Date: Thu, 6 Mar 2025 10:09:03 +0000 Subject: [PATCH 4/5] Add utility to identify computed field in querydsl --- README.md | 13 +++++++++++-- src/modules/search.js | 8 ++++++++ src/modules/search.test.js | 4 ++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6e3d50d..f52e8e4 100644 --- a/README.md +++ b/README.md @@ -1200,6 +1200,15 @@ s.greaterThan('field1', s.compareToField('field2')); // greaterThan: {field: field1, value: {field: field2}} ``` +##### computedField(field) + +Prefix field with identifier for a computed field. + +```js +s.computedField('computedField'); +// ':computedField' +``` + ##### contains(field, value) Build a criterion matching `MultiSelectList` and `Collection` fields which contains the provided value. @@ -1318,9 +1327,9 @@ const query = s.query( s.hasValue(s.data('field3'), true), s.is(s.data('yesOrNoField'), true), s.greaterThan(s.data('numberField1'), 10), - s.greaterThanOrEquals(s.data('numberField2'), 10), + s.greaterThanOrEquals(s.data('numberField2'), s.compareToField('numberField1')), s.lessThan(s.data('numberField3'), 10), - s.lessThanOrEquals(s.data('numberField4'), 10), + s.lessThanOrEquals(s.computedField('numberField4'), 10), ) ); diff --git a/src/modules/search.js b/src/modules/search.js index 19379f6..dbbd8ee 100644 --- a/src/modules/search.js +++ b/src/modules/search.js @@ -96,6 +96,13 @@ const compareToField = (field) => ({ field, }); +/** + * Return field name with a prefix to identify a computed field in querydsl + * @param computedFieldName - computed field name + * @returns {String} field name prefixed with identifier for computed field + */ +const computedField = (computedFieldName) => ':' + computedFieldName; + /** * Build optional search request parameters * @param additionalParams - optional additional parameters to be included in the search query parameters @@ -124,6 +131,7 @@ export const searchDsl = Object.freeze({ not, and, compareToField, + computedField, data, equals, equalsIgnoreCase, diff --git a/src/modules/search.test.js b/src/modules/search.test.js index 35b4e62..fb57dc0 100644 --- a/src/modules/search.test.js +++ b/src/modules/search.test.js @@ -247,6 +247,10 @@ describe('searchDsl', () => { expect(dsl.data('level1.level2')).toEqual('data.level1.level2'); expect(dsl.data('level1', 'level2')).toEqual('data.level1.level2'); expect(dsl.data(...['level1', 'level2'])).toEqual('data.level1.level2'); + }); + + test('should return computed field prefixed with identifier', () => { + expect(dsl.computedField('computedField')).toEqual(':computedField'); }) test('should return `greaterThan` matcher', () => { From 65398ff9449746596140f207456456de8e07e6a8 Mon Sep 17 00:00:00 2001 From: gbendikar Date: Thu, 6 Mar 2025 10:09:36 +0000 Subject: [PATCH 5/5] Fix npm audit vulnerabilities --- package-lock.json | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 28e1546..10ffc54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3476,10 +3476,11 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -9515,9 +9516,9 @@ } }, "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "requires": { "path-key": "^3.1.0",