diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bff62e9cf4..df6d3a3afc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Replace ElasticSearch Phase 1 - **CUMULUS-3692** - - Update granules List endpoints to query postgres for basic queries - + - Added `@cumulus/db/src/search` `BaseSearch` and `GranuleSearch` classes to + support basic queries for granules + - Updated granules List endpoint to query postgres for basic queries +- **CUMULUS-3694** + - Added functionality to `@cumulus/db/src/search` to support term queries + - Updated `BaseSearch` and `GranuleSearch` classes to support term queries for granules + - Updated granules List endpoint to search postgres ### Migration Notes diff --git a/example/spec/helpers/granuleUtils.js b/example/spec/helpers/granuleUtils.js index e73eece1e96..4dbc9720204 100644 --- a/example/spec/helpers/granuleUtils.js +++ b/example/spec/helpers/granuleUtils.js @@ -234,6 +234,7 @@ const waitForGranuleRecordUpdatedInList = async (stackName, granule, additionalQ 'beginningDateTime', 'endingDateTime', 'error', + 'execution', // TODO remove after CUMULUS-3698 'files', // TODO -2714 this should be removed 'lastUpdateDateTime', 'productionDateTime', diff --git a/example/spec/parallel/testAPI/granuleSpec.js b/example/spec/parallel/testAPI/granuleSpec.js index 2a977c079b2..e9d170fa9e6 100644 --- a/example/spec/parallel/testAPI/granuleSpec.js +++ b/example/spec/parallel/testAPI/granuleSpec.js @@ -183,7 +183,8 @@ describe('The Granules API', () => { }); const searchedGranule = JSON.parse(searchResults.body).results[0]; - expect(searchedGranule).toEqual(jasmine.objectContaining(randomGranuleRecord)); + // TODO CUMULUS-3698 includes files + expect(searchedGranule).toEqual(jasmine.objectContaining(omit(randomGranuleRecord, 'files'))); }); it('can modify the granule via API.', async () => { diff --git a/packages/api/endpoints/granules.js b/packages/api/endpoints/granules.js index 0f4b2cc1f55..f25e5bb262c 100644 --- a/packages/api/endpoints/granules.js +++ b/packages/api/endpoints/granules.js @@ -32,7 +32,6 @@ const { recordNotFoundString, multipleRecordFoundString, } = require('@cumulus/es-client/search'); -const ESSearchAfter = require('@cumulus/es-client/esSearchAfter'); const { deleteGranuleAndFiles } = require('../src/lib/granule-delete'); const { zodParser } = require('../src/zod-utils'); @@ -105,25 +104,9 @@ async function list(req, res) { log.trace(`list query ${JSON.stringify(req.query)}`); const { getRecoveryStatus, ...queryStringParameters } = req.query; - let es; - if (queryStringParameters.searchContext) { - es = new ESSearchAfter( - { queryStringParameters }, - 'granule', - process.env.ES_INDEX - ); - } else { - es = new Search({ queryStringParameters }, 'granule', process.env.ES_INDEX); - } - let result; - // TODO the condition should be removed after we support all the query parameters - if (Object.keys(queryStringParameters).filter((item) => !['limit', 'page', 'sort_key'].includes(item)).length === 0) { - log.debug('list perform db search'); - const dbSearch = new GranuleSearch({ queryStringParameters }); - result = await dbSearch.query(); - } else { - result = await es.query(); - } + const dbSearch = new GranuleSearch({ queryStringParameters }); + const result = await dbSearch.query(); + if (getRecoveryStatus === 'true') { return res.send(await addOrcaRecoveryStatus(result)); } diff --git a/packages/api/tests/endpoints/test-granules.js b/packages/api/tests/endpoints/test-granules.js index 90b8cd905a1..fc6f9425889 100644 --- a/packages/api/tests/endpoints/test-granules.js +++ b/packages/api/tests/endpoints/test-granules.js @@ -288,6 +288,7 @@ test.beforeEach(async (t) => { const granuleId1 = t.context.createGranuleId(); const granuleId2 = t.context.createGranuleId(); const granuleId3 = t.context.createGranuleId(); + const timestamp = new Date(); // create fake Postgres granule records t.context.fakePGGranules = [ @@ -299,21 +300,24 @@ test.beforeEach(async (t) => { cmr_link: 'https://cmr.uat.earthdata.nasa.gov/search/granules.json?concept_id=A123456789-TEST_A', duration: 47.125, - timestamp: new Date(Date.now()), + timestamp, + updated_at: timestamp, }), fakeGranuleRecordFactory({ granule_id: granuleId2, status: 'failed', collection_cumulus_id: t.context.collectionCumulusId, duration: 52.235, - timestamp: new Date(Date.now()), + timestamp, + updated_at: timestamp, }), fakeGranuleRecordFactory({ granule_id: granuleId3, status: 'failed', collection_cumulus_id: t.context.collectionCumulusId, duration: 52.235, - timestamp: new Date(Date.now()), + timestamp, + updated_at: timestamp, }), // granule with same granule_id as above but different collection_cumulus_id fakeGranuleRecordFactory({ @@ -321,7 +325,8 @@ test.beforeEach(async (t) => { status: 'failed', collection_cumulus_id: t.context.collectionCumulusId2, duration: 52.235, - timestamp: new Date(Date.now()), + timestamp, + updated_at: timestamp, }), ]; @@ -456,7 +461,7 @@ test.serial('default lists and paginates correctly from querying database', asyn const { meta, results } = response.body; t.is(results.length, 4); t.is(meta.stack, process.env.stackName); - t.is(meta.table, 'granule'); + t.is(meta.table, 'granules'); t.is(meta.count, 4); results.forEach((r) => { t.true(granuleIds.includes(r.granuleId)); @@ -487,6 +492,41 @@ test.serial('default lists and paginates correctly from querying database', asyn t.not(results[0].granuleId, newResults[0].granuleId); }); +test.serial('LIST endpoint returns search result correctly', async (t) => { + const granuleIds = t.context.fakePGGranules.map((i) => i.granule_id); + const searchParams = new URLSearchParams({ + granuleId: granuleIds[3], + }); + const response = await request(app) + .get(`/granules?limit=1&page=2&${searchParams}`) + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${jwtAuthToken}`) + .expect(200); + + const { meta, results } = response.body; + t.is(meta.count, 2); + t.is(results.length, 1); + t.true([granuleIds[2], granuleIds[3]].includes(results[0].granuleId)); + + const newSearchParams = new URLSearchParams({ + collectionId: t.context.collectionId, + status: 'failed', + duration: 52.235, + timestamp: t.context.fakePGGranules[0].timestamp.getTime(), + }); + const newResponse = await request(app) + .get(`/granules?${newSearchParams}`) + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${jwtAuthToken}`) + .expect(200); + + const { meta: newMeta, results: newResults } = newResponse.body; + t.is(newMeta.count, 2); + t.is(newResults.length, 2); + const newResultIds = newResults.map((g) => g.granuleId); + t.deepEqual([granuleIds[1], granuleIds[2]].sort(), newResultIds.sort()); +}); + test.serial('CUMULUS-911 GET without pathParameters and without an Authorization header returns an Authorization Missing response', async (t) => { const response = await request(app) .get('/granules') diff --git a/packages/db/src/search/BaseSearch.ts b/packages/db/src/search/BaseSearch.ts index 00b703e9897..dd1fc0cd063 100644 --- a/packages/db/src/search/BaseSearch.ts +++ b/packages/db/src/search/BaseSearch.ts @@ -1,8 +1,11 @@ import { Knex } from 'knex'; import Logger from '@cumulus/logger'; -import { getKnexClient } from '../connection'; + import { BaseRecord } from '../types/base'; +import { getKnexClient } from '../connection'; +import { TableNames } from '../tables'; import { DbQueryParameters, QueryEvent, QueryStringParameters } from '../types/search'; +import { convertQueryStringToDbQueryParameters } from './queries'; const log = new Logger({ sender: '@cumulus/db/BaseSearch' }); @@ -15,32 +18,35 @@ export type Meta = { count?: number, }; +const typeToTable: { [key: string]: string } = { + asyncOperation: TableNames.asyncOperations, + collection: TableNames.collections, + execution: TableNames.executions, + granule: TableNames.granules, + pdr: TableNames.pdrs, + provider: TableNames.providers, + rule: TableNames.rules, +}; + /** * Class to build and execute db search query */ class BaseSearch { - readonly type?: string; + readonly type: string; readonly queryStringParameters: QueryStringParameters; // parsed from queryStringParameters for query build dbQueryParameters: DbQueryParameters = {}; - constructor(event: QueryEvent, type?: string) { + constructor(event: QueryEvent, type: string) { this.type = type; this.queryStringParameters = event?.queryStringParameters ?? {}; - this.dbQueryParameters.page = Number.parseInt( - (this.queryStringParameters.page) ?? '1', - 10 - ); - this.dbQueryParameters.limit = Number.parseInt( - (this.queryStringParameters.limit) ?? '10', - 10 + this.dbQueryParameters = convertQueryStringToDbQueryParameters( + this.type, this.queryStringParameters ); - this.dbQueryParameters.offset = (this.dbQueryParameters.page - 1) - * this.dbQueryParameters.limit; } /** - * build the search query + * Build the search query * * @param knex - DB client * @returns queries for getting count and search result @@ -51,14 +57,19 @@ class BaseSearch { searchQuery: Knex.QueryBuilder, } { const { countQuery, searchQuery } = this.buildBasicQuery(knex); - if (this.dbQueryParameters.limit) searchQuery.limit(this.dbQueryParameters.limit); - if (this.dbQueryParameters.offset) searchQuery.offset(this.dbQueryParameters.offset); + this.buildTermQuery({ countQuery, searchQuery }); + this.buildInfixPrefixQuery({ countQuery, searchQuery }); + const { limit, offset } = this.dbQueryParameters; + if (limit) searchQuery.limit(limit); + if (offset) searchQuery.offset(offset); + + log.debug(`_buildSearch returns countQuery: ${countQuery.toSQL().sql}, searchQuery: ${searchQuery.toSQL().sql}`); return { countQuery, searchQuery }; } /** - * metadata template for query result + * Get metadata template for query result * * @returns metadata template */ @@ -66,12 +77,12 @@ class BaseSearch { return { name: 'cumulus-api', stack: process.env.stackName, - table: this.type, + table: this.type && typeToTable[this.type], }; } /** - * build basic query + * Build basic query * * @param knex - DB client * @throws - function is not implemented @@ -84,6 +95,46 @@ class BaseSearch { throw new Error('buildBasicQuery is not implemented'); } + /** + * Build queries for infix and prefix + * + * @param params + * @param params.countQuery - query builder for getting count + * @param params.searchQuery - query builder for search + * @param [params.dbQueryParameters] - db query parameters + */ + protected buildInfixPrefixQuery(params: { + countQuery: Knex.QueryBuilder, + searchQuery: Knex.QueryBuilder, + dbQueryParameters?: DbQueryParameters, + }) { + log.debug(`buildInfixPrefixQuery is not implemented ${Object.keys(params)}`); + throw new Error('buildInfixPrefixQuery is not implemented'); + } + + /** + * Build queries for term fields + * + * @param params + * @param params.countQuery - query builder for getting count + * @param params.searchQuery - query builder for search + * @param [params.dbQueryParameters] - db query parameters + */ + protected buildTermQuery(params: { + countQuery: Knex.QueryBuilder, + searchQuery: Knex.QueryBuilder, + dbQueryParameters?: DbQueryParameters, + }) { + const table = typeToTable[this.type]; + const { countQuery, searchQuery, dbQueryParameters } = params; + const { term = {} } = dbQueryParameters || this.dbQueryParameters; + + Object.entries(term).forEach(([name, value]) => { + countQuery.where(`${table}.${name}`, value); + searchQuery.where(`${table}.${name}`, value); + }); + } + /** * Translate postgres records to api records * @@ -96,7 +147,7 @@ class BaseSearch { } /** - * build and execute search query + * Build and execute search query * * @param testKnex - knex for testing * @returns search result diff --git a/packages/db/src/search/GranuleSearch.ts b/packages/db/src/search/GranuleSearch.ts index 8ff2ec6eb74..b875dae52fe 100644 --- a/packages/db/src/search/GranuleSearch.ts +++ b/packages/db/src/search/GranuleSearch.ts @@ -1,17 +1,18 @@ import { Knex } from 'knex'; +import omit from 'lodash/omit'; +import pick from 'lodash/pick'; import { ApiGranuleRecord } from '@cumulus/types/api/granules'; import Logger from '@cumulus/logger'; import { BaseRecord } from '../types/base'; import { BaseSearch } from './BaseSearch'; +import { DbQueryParameters, QueryEvent } from '../types/search'; import { PostgresGranuleRecord } from '../types/granule'; -import { QueryEvent } from '../types/search'; - -import { TableNames } from '../tables'; import { translatePostgresGranuleToApiGranuleWithoutDbQuery } from '../translate/granules'; +import { TableNames } from '../tables'; -const log = new Logger({ sender: '@cumulus/db/BaseSearch' }); +const log = new Logger({ sender: '@cumulus/db/GranuleSearch' }); export interface GranuleRecord extends BaseRecord, PostgresGranuleRecord { cumulus_id: number, @@ -25,6 +26,8 @@ export interface GranuleRecord extends BaseRecord, PostgresGranuleRecord { providerName?: string, } +const foreignFields = ['collectionName', 'collectionVersion', 'providerName', 'pdrName']; + /** * Class to build and execute db search query for granules */ @@ -33,8 +36,23 @@ export class GranuleSearch extends BaseSearch { super(event, 'granule'); } + private searchCollection(): boolean { + const term = this.dbQueryParameters.term; + return !!(term && (term.collectionName || term.collectionVersion)); + } + + private searchPdr(): boolean { + const term = this.dbQueryParameters.term; + return !!(term && term.pdrName); + } + + private searchProvider(): boolean { + const term = this.dbQueryParameters.term; + return !!(term && term.providerName); + } + /** - * build basic query + * Build basic query * * @param knex - DB client * @returns queries for getting count and search result @@ -61,19 +79,114 @@ export class GranuleSearch extends BaseSearch { collectionVersion: `${collectionsTable}.version`, pdrName: `${pdrsTable}.name`, }) - .innerJoin(collectionsTable, `${granulesTable}.collection_cumulus_id`, `${collectionsTable}.cumulus_id`) - .leftJoin(providersTable, `${granulesTable}.provider_cumulus_id`, `${providersTable}.cumulus_id`) - .leftJoin(pdrsTable, `${granulesTable}.pdr_cumulus_id`, `${pdrsTable}.cumulus_id`); + .innerJoin(collectionsTable, `${granulesTable}.collection_cumulus_id`, `${collectionsTable}.cumulus_id`); + + if (this.searchCollection()) { + countQuery.innerJoin(collectionsTable, `${granulesTable}.collection_cumulus_id`, `${collectionsTable}.cumulus_id`); + } + + if (this.searchProvider()) { + countQuery.innerJoin(providersTable, `${granulesTable}.provider_cumulus_id`, `${providersTable}.cumulus_id`); + searchQuery.innerJoin(providersTable, `${granulesTable}.provider_cumulus_id`, `${providersTable}.cumulus_id`); + } else { + searchQuery.leftJoin(providersTable, `${granulesTable}.provider_cumulus_id`, `${providersTable}.cumulus_id`); + } + + if (this.searchPdr()) { + countQuery.innerJoin(pdrsTable, `${granulesTable}.pdr_cumulus_id`, `${pdrsTable}.cumulus_id`); + searchQuery.innerJoin(pdrsTable, `${granulesTable}.pdr_cumulus_id`, `${pdrsTable}.cumulus_id`); + } else { + searchQuery.leftJoin(pdrsTable, `${granulesTable}.pdr_cumulus_id`, `${pdrsTable}.cumulus_id`); + } return { countQuery, searchQuery }; } + /** + * Build queries for infix and prefix + * + * @param params + * @param params.countQuery - query builder for getting count + * @param params.searchQuery - query builder for search + * @param [params.dbQueryParameters] - db query parameters + */ + protected buildInfixPrefixQuery(params: { + countQuery: Knex.QueryBuilder, + searchQuery: Knex.QueryBuilder, + dbQueryParameters?: DbQueryParameters, + }) { + const { granules: granulesTable } = TableNames; + const { countQuery, searchQuery, dbQueryParameters } = params; + const { infix, prefix } = dbQueryParameters || this.dbQueryParameters; + if (infix) { + countQuery.whereLike(`${granulesTable}.granule_id`, `%${infix}%`); + searchQuery.whereLike(`${granulesTable}.granule_id`, `%${infix}%`); + } + if (prefix) { + countQuery.whereLike(`${granulesTable}.granule_id`, `${prefix}%`); + searchQuery.whereLike(`${granulesTable}.granule_id`, `${prefix}%`); + } + } + + /** + * Build queries for term fields + * + * @param params + * @param params.countQuery - query builder for getting count + * @param params.searchQuery - query builder for search + * @param [params.dbQueryParameters] - db query parameters + */ + protected buildTermQuery(params: { + countQuery: Knex.QueryBuilder, + searchQuery: Knex.QueryBuilder, + dbQueryParameters?: DbQueryParameters, + }) { + const { + granules: granulesTable, + collections: collectionsTable, + providers: providersTable, + pdrs: pdrsTable, + } = TableNames; + + const { countQuery, searchQuery, dbQueryParameters } = params; + const { term = {} } = dbQueryParameters || this.dbQueryParameters; + + Object.entries(term).forEach(([name, value]) => { + if (name === 'collectionName') { + countQuery.where(`${collectionsTable}.name`, value); + searchQuery.where(`${collectionsTable}.name`, value); + } + if (name === 'collectionVersion') { + countQuery.where(`${collectionsTable}.version`, value); + searchQuery.where(`${collectionsTable}.version`, value); + } + if (name === 'providerName') { + countQuery.where(`${providersTable}.name`, value); + searchQuery.where(`${providersTable}.name`, value); + } + if (name === 'pdrName') { + countQuery.where(`${pdrsTable}.name`, value); + searchQuery.where(`${pdrsTable}.name`, value); + } + if (name === 'error.Error') { + countQuery.whereRaw(`${granulesTable}.error->>'Error' = '${value}'`); + searchQuery.whereRaw(`${granulesTable}.error->>'Error' = '${value}'`); + } + }); + + super.buildTermQuery({ + ...params, + dbQueryParameters: { term: omit(term, foreignFields, 'error.Error') }, + }); + } + /** * Translate postgres records to api records * * @param pgRecords - postgres records returned from query * @returns translated api records */ - protected translatePostgresRecordsToApiRecords(pgRecords: GranuleRecord[]) : ApiGranuleRecord[] { + protected translatePostgresRecordsToApiRecords(pgRecords: GranuleRecord[]) + : Partial[] { log.debug(`translatePostgresRecordsToApiRecords number of records ${pgRecords.length} `); const apiRecords = pgRecords.map((item: GranuleRecord) => { const granulePgRecord = item; @@ -84,9 +197,12 @@ export class GranuleSearch extends BaseSearch { }; const pdr = item.pdrName ? { name: item.pdrName } : undefined; const providerPgRecord = item.providerName ? { name: item.providerName } : undefined; - return translatePostgresGranuleToApiGranuleWithoutDbQuery({ + const apiRecord = translatePostgresGranuleToApiGranuleWithoutDbQuery({ granulePgRecord, collectionPgRecord, pdr, providerPgRecord, }); + return this.dbQueryParameters.fields + ? pick(apiRecord, this.dbQueryParameters.fields) + : apiRecord; }); return apiRecords; } diff --git a/packages/db/src/search/field-mapping.ts b/packages/db/src/search/field-mapping.ts new file mode 100644 index 00000000000..64a243ff618 --- /dev/null +++ b/packages/db/src/search/field-mapping.ts @@ -0,0 +1,223 @@ +import { deconstructCollectionId } from '@cumulus/message/Collections'; +import Logger from '@cumulus/logger'; + +const log = new Logger({ sender: '@cumulus/db/field-mapping' }); + +// functions to map the api search string field name and value to postgres db field +const granuleMapping: { [key: string]: Function } = { + beginningDateTime: (value?: string) => ({ + beginning_date_time: value, + }), + cmrLink: (value?: string) => ({ + cmr_link: value, + }), + createdAt: (value?: string) => ({ + created_at: value && new Date(Number(value)), + }), + duration: (value?: string) => ({ + duration: value && Number(value), + }), + endingDateTime: (value?: string) => ({ + ending_date_time: value, + }), + granuleId: (value?: string) => ({ + granule_id: value, + }), + lastUpdateDateTime: (value?: string) => ({ + last_update_date_time: value, + }), + processingEndDateTime: (value?: string) => ({ + processing_end_date_time: value, + }), + processingStartDateTime: (value?: string) => ({ + processing_start_date_time: value, + }), + productionDateTime: (value?: string) => ({ + production_date_time: value, + }), + productVolume: (value?: string) => ({ + product_volume: value, + }), + published: (value?: string) => ({ + published: (value === 'true'), + }), + status: (value?: string) => ({ + status: value, + }), + timestamp: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), + timeToArchive: (value?: string) => ({ + time_to_archive: Number(value), + }), + timeToPreprocess: (value?: string) => ({ + time_to_process: Number(value), + }), + updatedAt: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), + // nested error field + 'error.Error': (value?: string) => ({ + 'error.Error': value, + }), + // The following fields require querying other tables + collectionId: (value?: string) => { + const { name, version } = (value && deconstructCollectionId(value)) || {}; + return { + collectionName: name, + collectionVersion: version, + }; + }, + provider: (value?: string) => ({ + providerName: value, + }), + pdrName: (value?: string) => ({ + pdrName: value, + }), +}; + +// TODO add and verify all queryable fields for the following record types +const asyncOperationMapping : { [key: string]: Function } = { + createdAt: (value?: string) => ({ + created_at: value && new Date(Number(value)), + }), + id: (value?: string) => ({ + id: value, + }), + operationType: (value?: string) => ({ + operation_type: value, + }), + status: (value?: string) => ({ + status: value, + }), + taskArn: (value?: string) => ({ + task_arn: value, + }), + timestamp: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), + updatedAt: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), +}; + +const collectionMapping : { [key: string]: Function } = { + createdAt: (value?: string) => ({ + created_at: value && new Date(Number(value)), + }), + name: (value?: string) => ({ + name: value, + }), + version: (value?: string) => ({ + version: value, + }), + timestamp: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), + updatedAt: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), +}; + +const executionMapping : { [key: string]: Function } = { + arn: (value?: string) => ({ + arn: value, + }), + createdAt: (value?: string) => ({ + created_at: value && new Date(Number(value)), + }), + execution: (value?: string) => ({ + url: value, + }), + status: (value?: string) => ({ + status: value, + }), + timestamp: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), + updatedAt: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), +}; + +const pdrMapping : { [key: string]: Function } = { + createdAt: (value?: string) => ({ + created_at: value && new Date(Number(value)), + }), + pdrName: (value?: string) => ({ + name: value, + }), + status: (value?: string) => ({ + status: value, + }), + timestamp: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), + updatedAt: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), +}; + +const providerMapping : { [key: string]: Function } = { + createdAt: (value?: string) => ({ + created_at: value && new Date(Number(value)), + }), + id: (value?: string) => ({ + name: value, + }), + timestamp: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), + updatedAt: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), +}; + +const ruleMapping : { [key: string]: Function } = { + createdAt: (value?: string) => ({ + created_at: value && new Date(Number(value)), + }), + name: (value?: string) => ({ + name: value, + }), + state: (value?: string) => ({ + enabled: (value === 'ENABLED'), + }), + timestamp: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), + updatedAt: (value?: string) => ({ + updated_at: value && new Date(Number(value)), + }), +}; + +// type and its mapping +const supportedMappings: { [key: string]: any } = { + granule: granuleMapping, + asyncOperation: asyncOperationMapping, + collection: collectionMapping, + execution: executionMapping, + pdr: pdrMapping, + provider: providerMapping, + rule: ruleMapping, +}; + +/** + * Map query string field to db field + * + * @param type - query record type + * @param queryField - query field + * @param queryField.name - query field value + * @param [queryField.value] - query field value + * @returns db field + */ +export const mapQueryStringFieldToDbField = ( + type: string, + queryField: { name: string, value?: string } +): { [key: string]: any } | undefined => { + if (!(supportedMappings[type] && supportedMappings[type][queryField.name])) { + log.warn(`No db mapping field found for type: ${type}, field ${JSON.stringify(queryField)}`); + return undefined; + } + return supportedMappings[type] && supportedMappings[type][queryField.name](queryField.value); +}; diff --git a/packages/db/src/search/queries.ts b/packages/db/src/search/queries.ts new file mode 100644 index 00000000000..32bf6ac0482 --- /dev/null +++ b/packages/db/src/search/queries.ts @@ -0,0 +1,100 @@ +import omit from 'lodash/omit'; +import Logger from '@cumulus/logger'; +import { DbQueryParameters, QueryStringParameters } from '../types/search'; +import { mapQueryStringFieldToDbField } from './field-mapping'; + +const log = new Logger({ sender: '@cumulus/db/queries' }); + +// reserved words which are not record fields +const reservedWords = [ + 'limit', + 'page', + 'skip', + 'sort_by', + 'sort_key', + 'order', + 'prefix', + 'infix', + 'fields', + 'searchContext', +]; + +/** + * regexp for matching api query string parameter to query type + */ +const regexes: { [key: string]: RegExp } = { + terms: /^(.*)__in$/, + term: /^((?!__).)*$/, + not: /^(.*)__not$/, + exists: /^(.*)__exists$/, + range: /^(.*)__(from|to)$/, +}; + +/** + * Conert term query fields to db query parameters from api query string fields + * + * @param type - query record type + * @param queryStringFields - api query fields + * @returns term query parameter + */ +const convertTerm = ( + type: string, + queryStringFields: { name: string, value: string }[] +): { term: { [key: string]: any } } => { + const term = queryStringFields.reduce((acc, queryField) => { + const queryParam = mapQueryStringFieldToDbField(type, queryField); + return { ...acc, ...queryParam }; + }, {}); + + return { term }; +}; + +/** + * functions for converting from api query string parameters to db query parameters + * for each type of query + */ +const convert: { [key: string]: Function } = { + term: convertTerm, +}; + +/** + * Convert api query string parameters to db query parameters + * + * @param type - query record type + * @param queryStringParameters - query string parameters + * @returns db query parameters + */ +export const convertQueryStringToDbQueryParameters = ( + type: string, + queryStringParameters: QueryStringParameters +): DbQueryParameters => { + const { limit, page, prefix, infix, fields } = queryStringParameters; + + const dbQueryParameters: DbQueryParameters = {}; + dbQueryParameters.page = Number.parseInt(page ?? '1', 10); + dbQueryParameters.limit = Number.parseInt(limit ?? '10', 10); + dbQueryParameters.offset = (dbQueryParameters.page - 1) * dbQueryParameters.limit; + + if (typeof infix === 'string') dbQueryParameters.infix = infix; + if (typeof prefix === 'string') dbQueryParameters.prefix = prefix; + if (typeof fields === 'string') dbQueryParameters.fields = fields.split(','); + + // remove reserved words (that are not fields) + const fieldParams = omit(queryStringParameters, reservedWords); + // determine which search strategy should be applied + // options are term, terms, range, exists and not in + const fieldsList = Object.entries(fieldParams).map(([name, value]) => ({ name, value })); + + // for each search strategy, get all parameters and convert them to db parameters + Object.keys(regexes).forEach((k: string) => { + const matchedFields = fieldsList.filter((f) => f.name.match(regexes[k])); + + if (matchedFields && matchedFields.length > 0 && convert[k]) { + const queryParams = convert[k](type, matchedFields, regexes[k]); + Object.assign(dbQueryParameters, queryParams); + } + }); + + log.debug(`convertQueryStringToDbQueryParameters returns ${JSON.stringify(dbQueryParameters)}`); + return dbQueryParameters; +}; diff --git a/packages/db/src/types/search.ts b/packages/db/src/types/search.ts index 50a3664ef48..1a40a093833 100644 --- a/packages/db/src/types/search.ts +++ b/packages/db/src/types/search.ts @@ -1,6 +1,12 @@ export type QueryStringParameters = { + fields?: string, + infix?: string, limit?: string, page?: string, + order?: string, + prefix?: string, + sort_by?: string, + sort_key?: string, [key: string]: string | string[] | undefined, }; @@ -9,7 +15,12 @@ export type QueryEvent = { }; export type DbQueryParameters = { + infix?: string, limit?: number, offset?: number, page?: number, + prefix?: string, + fields?: string[], + term?: { [key: string]: any }, + terms?: { [key: string]: any }, }; diff --git a/packages/db/tests/search/test-GranuleSearch.js b/packages/db/tests/search/test-GranuleSearch.js index a18690d70b0..ffad472c444 100644 --- a/packages/db/tests/search/test-GranuleSearch.js +++ b/packages/db/tests/search/test-GranuleSearch.js @@ -20,6 +20,14 @@ const { const testDbName = `granule_${cryptoRandomString({ length: 10 })}`; +// generate granuleId for infix and prefix search +const generateGranuleId = (num) => { + let granuleId = cryptoRandomString({ length: 10 }); + if (num % 30 === 0) granuleId = `${cryptoRandomString({ length: 5 })}infix${cryptoRandomString({ length: 5 })}`; + if (num % 50 === 0) granuleId = `prefix${cryptoRandomString({ length: 10 })}`; + return granuleId; +}; + test.before(async (t) => { const { knexAdmin, knex } = await generateLocalTestDb( testDbName, @@ -89,19 +97,57 @@ test.before(async (t) => { t.context.pdrCumulusId = pgPdr.cumulus_id; // Create Granule + t.context.granuleSearchFields = { + beginningDateTime: '2020-03-16T19:50:24.757Z', + cmrLink: 'https://fakeLink', + duration: '6.8', + endingDateTime: '2020-03-17T10:00:00.000Z', + lastUpdateDateTime: '2020-03-18T10:00:00.000Z', + processingEndDateTime: '2020-03-16T10:00:00.000Z', + productVolume: '600', + timeToArchive: '700.29', + timeToPreprocess: '800.18', + status: 'failed', + timestamp: 1579352700000, + updatedAt: 1579352700000, + }; + + const error = { + Cause: 'cause string', + Error: 'CumulusMessageAdapterExecutionError', + }; + t.context.granulePgModel = new GranulePgModel(); t.context.pgGranules = await t.context.granulePgModel.insert( knex, range(100).map((num) => fakeGranuleRecordFactory({ + granule_id: generateGranuleId(num), collection_cumulus_id: (num % 2) ? t.context.collectionCumulusId : t.context.collectionCumulusId2, - pdr_cumulus_id: t.context.pdrCumulusId, - provider_cumulus_id: t.context.providerCumulusId, + pdr_cumulus_id: !(num % 2) ? t.context.pdrCumulusId : undefined, + provider_cumulus_id: !(num % 2) ? t.context.providerCumulusId : undefined, + beginning_date_time: !(num % 2) + ? new Date(t.context.granuleSearchFields.beginningDateTime) : undefined, + cmr_link: !(num % 100) ? t.context.granuleSearchFields.cmrLink : undefined, + duration: !(num % 2) ? Number(t.context.granuleSearchFields.duration) : undefined, + ending_date_time: !(num % 2) + ? new Date(t.context.granuleSearchFields.endingDateTime) : new Date(), + error: !(num % 2) ? JSON.stringify(error) : undefined, + last_update_date_time: !(num % 2) + ? t.context.granuleSearchFields.lastUpdateDateTime : undefined, + published: !!(num % 2), + product_volume: !(num % 5) ? Number(t.context.granuleSearchFields.productVolume) : undefined, + time_to_archive: !(num % 10) + ? Number(t.context.granuleSearchFields.timeToArchive) : undefined, + time_to_process: !(num % 20) + ? Number(t.context.granuleSearchFields.timeToPreprocess) : undefined, + status: !(num % 2) ? t.context.granuleSearchFields.status : 'completed', + updated_at: !(num % 2) ? new Date(t.context.granuleSearchFields.timestamp) : undefined, })) ); }); -test('Granule search returns 10 granule records by default', async (t) => { +test('GranuleSearch returns 10 granule records by default', async (t) => { const { knex } = t.context; const dbSearch = new GranuleSearch(); const response = await dbSearch.query(knex); @@ -112,12 +158,12 @@ test('Granule search returns 10 granule records by default', async (t) => { t.is(apiGranules.length, 10); const validatedRecords = apiGranules.filter((granule) => ( [t.context.collectionId, t.context.collectionId2].includes(granule.collectionId) - && granule.provider === t.context.provider.name - && granule.pdrName === t.context.pdr.name)); + && (!granule.provider || granule.provider === t.context.provider.name) + && (!granule.pdrName || granule.pdrName === t.context.pdr.name))); t.is(validatedRecords.length, apiGranules.length); }); -test('Granule search supports page and limit params', async (t) => { +test('GranuleSearch supports page and limit params', async (t) => { const { knex } = t.context; let queryStringParameters = { limit: 20, @@ -146,3 +192,198 @@ test('Granule search supports page and limit params', async (t) => { t.is(response.meta.count, 100); t.is(response.results?.length, 0); }); + +test('GranuleSearch supports infix search', async (t) => { + const { knex } = t.context; + const queryStringParameters = { + limit: 200, + infix: 'infix', + }; + const dbSearch = new GranuleSearch({ queryStringParameters }); + const response = await dbSearch.query(knex); + t.is(response.meta.count, 3); + t.is(response.results?.length, 3); +}); + +test('GranuleSearch supports prefix search', async (t) => { + const { knex } = t.context; + const queryStringParameters = { + limit: 200, + prefix: 'prefix', + }; + const dbSearch = new GranuleSearch({ queryStringParameters }); + const response = await dbSearch.query(knex); + t.is(response.meta.count, 2); + t.is(response.results?.length, 2); +}); + +test('GranuleSearch supports collectionId term search', async (t) => { + const { knex } = t.context; + const queryStringParameters = { + limit: 200, + collectionId: t.context.collectionId2, + }; + const dbSearch = new GranuleSearch({ queryStringParameters }); + const response = await dbSearch.query(knex); + t.is(response.meta.count, 50); + t.is(response.results?.length, 50); +}); + +test('GranuleSearch supports provider term search', async (t) => { + const { knex } = t.context; + const queryStringParameters = { + limit: 200, + provider: t.context.provider.name, + }; + const dbSearch = new GranuleSearch({ queryStringParameters }); + const response = await dbSearch.query(knex); + t.is(response.meta.count, 50); + t.is(response.results?.length, 50); +}); + +test('GranuleSearch supports pdrName term search', async (t) => { + const { knex } = t.context; + const queryStringParameters = { + limit: 200, + pdrName: t.context.pdr.name, + }; + const dbSearch = new GranuleSearch({ queryStringParameters }); + const response = await dbSearch.query(knex); + t.is(response.meta.count, 50); + t.is(response.results?.length, 50); +}); + +test('GranuleSearch supports term search for boolean field', async (t) => { + const { knex } = t.context; + const queryStringParameters = { + limit: 200, + published: 'true', + }; + const dbSearch = new GranuleSearch({ queryStringParameters }); + const response = await dbSearch.query(knex); + t.is(response.meta.count, 50); + t.is(response.results?.length, 50); +}); + +test('GranuleSearch supports term search for date field', async (t) => { + const { knex } = t.context; + const queryStringParameters = { + limit: 200, + beginningDateTime: t.context.granuleSearchFields.beginningDateTime, + endingDateTime: t.context.granuleSearchFields.endingDateTime, + lastUpdateDateTime: t.context.granuleSearchFields.lastUpdateDateTime, + updatedAt: t.context.granuleSearchFields.updatedAt, + }; + const dbSearch = new GranuleSearch({ queryStringParameters }); + const response = await dbSearch.query(knex); + t.is(response.meta.count, 50); + t.is(response.results?.length, 50); +}); + +test('GranuleSearch supports term search for number field', async (t) => { + const { knex } = t.context; + let queryStringParameters = { + limit: 5, + duration: t.context.granuleSearchFields.duration, + productVolume: t.context.granuleSearchFields.productVolume, + }; + let dbSearch = new GranuleSearch({ queryStringParameters }); + let response = await dbSearch.query(knex); + t.is(response.meta.count, 10); + t.is(response.results?.length, 5); + + queryStringParameters = { + limit: 200, + timeToArchive: t.context.granuleSearchFields.timeToArchive, + timeToPreprocess: t.context.granuleSearchFields.timeToPreprocess, + }; + dbSearch = new GranuleSearch({ queryStringParameters }); + response = await dbSearch.query(knex); + t.is(response.meta.count, 5); + t.is(response.results?.length, 5); +}); + +test('GranuleSearch supports term search for string field', async (t) => { + const { knex } = t.context; + let queryStringParameters = { + limit: 200, + status: t.context.granuleSearchFields.status, + }; + let dbSearch = new GranuleSearch({ queryStringParameters }); + let response = await dbSearch.query(knex); + t.is(response.meta.count, 50); + t.is(response.results?.length, 50); + + queryStringParameters = { + limit: 200, + cmrLink: t.context.granuleSearchFields.cmrLink, + }; + dbSearch = new GranuleSearch({ queryStringParameters }); + response = await dbSearch.query(knex); + t.is(response.meta.count, 1); + t.is(response.results?.length, 1); +}); + +test('GranuleSearch supports term search for timestamp', async (t) => { + const { knex } = t.context; + const queryStringParameters = { + limit: 200, + timestamp: t.context.granuleSearchFields.timestamp, + }; + const dbSearch = new GranuleSearch({ queryStringParameters }); + const response = await dbSearch.query(knex); + t.is(response.meta.count, 50); + t.is(response.results?.length, 50); +}); + +test('GranuleSearch supports term search for nested error.Error', async (t) => { + const { knex } = t.context; + const queryStringParameters = { + limit: 200, + 'error.Error': 'CumulusMessageAdapterExecutionError', + }; + const dbSearch = new GranuleSearch({ queryStringParameters }); + const response = await dbSearch.query(knex); + t.is(response.meta.count, 50); + t.is(response.results?.length, 50); +}); + +test('GranuleSearch supports term search for multiple fields', async (t) => { + const { knex } = t.context; + const queryStringParameters = { + limit: 200, + collectionId: t.context.collectionId2, + provider: t.context.provider.name, + 'error.Error': 'CumulusMessageAdapterExecutionError', + status: 'failed', + }; + const dbSearch = new GranuleSearch({ queryStringParameters }); + const response = await dbSearch.query(knex); + t.is(response.meta.count, 50); + t.is(response.results?.length, 50); +}); + +test('GranuleSearch non-existing fields are ignored', async (t) => { + const { knex } = t.context; + const queryStringParameters = { + limit: 200, + non_existing_field: `non_exist_${cryptoRandomString({ length: 5 })}`, + }; + const dbSearch = new GranuleSearch({ queryStringParameters }); + const response = await dbSearch.query(knex); + t.is(response.meta.count, 100); + t.is(response.results?.length, 100); +}); + +test('GranuleSearch returns fields specified', async (t) => { + const { knex } = t.context; + const fields = 'granuleId,endingDateTime,collectionId,published,status'; + const queryStringParameters = { + fields, + }; + const dbSearch = new GranuleSearch({ queryStringParameters }); + const response = await dbSearch.query(knex); + t.is(response.meta.count, 100); + t.is(response.results?.length, 10); + response.results.forEach((granule) => t.deepEqual(Object.keys(granule), fields.split(','))); +}); diff --git a/packages/db/tests/search/test-field-mapping.js b/packages/db/tests/search/test-field-mapping.js new file mode 100644 index 00000000000..4a93a2d21a3 --- /dev/null +++ b/packages/db/tests/search/test-field-mapping.js @@ -0,0 +1,222 @@ +const test = require('ava'); +const { + mapQueryStringFieldToDbField, +} = require('../../dist/search/field-mapping'); + +test('mapQueryStringFieldToDbField converts an api field to db field', (t) => { + const querStringField = { name: 'beginningDateTime', value: '2017-10-24T00:00:00.000Z' }; + const dbQueryParam = mapQueryStringFieldToDbField('granule', querStringField); + const expectedResult = { beginning_date_time: '2017-10-24T00:00:00.000Z' }; + t.deepEqual(dbQueryParam, expectedResult); +}); + +test('mapQueryStringFieldToDbField returns undefined if the api field is not supported', (t) => { + const querStringField = { name: 'apiNoMatchingDbField', value: '2017-10-24T00:00:00.000Z' }; + const dbQueryParam = mapQueryStringFieldToDbField('granule', querStringField); + t.falsy(dbQueryParam); +}); + +test('mapQueryStringFieldToDbField correctly converts all granule api fields to db fields', (t) => { + const queryStringParameters = { + beginningDateTime: '2017-10-24T00:00:00.000Z', + cmrLink: 'example.com', + createdAt: '1591312763823', + duration: '26.939', + endingDateTime: '2017-11-08T23:59:59.000Z', + granuleId: 'MOD09GQ.A1657416.CbyoRi.006.9697917818587', + lastUpdateDateTime: '2018-04-25T21:45:45.524Z', + processingEndDateTime: '2018-09-24T23:28:45.731Z', + processingStartDateTime: '2018-09-24T22:52:34.578Z', + productionDateTime: '2018-07-19T12:01:01Z', + productVolume: '17956339', + published: 'true', + status: 'completed', + timestamp: '1576106371369', + timeToArchive: '5.6', + timeToPreprocess: '10.892', + 'error.Error': 'CumulusMessageAdapterExecutionError', + collectionId: 'MOD09GQ___006', + provider: 's3_provider', + pdrName: 'MOD09GQ_1granule_v3.PDR', + }; + + const expectedDbParameters = { + beginning_date_time: '2017-10-24T00:00:00.000Z', + cmr_link: 'example.com', + created_at: new Date(1591312763823), + duration: 26.939, + ending_date_time: '2017-11-08T23:59:59.000Z', + granule_id: 'MOD09GQ.A1657416.CbyoRi.006.9697917818587', + last_update_date_time: '2018-04-25T21:45:45.524Z', + processing_end_date_time: '2018-09-24T23:28:45.731Z', + processing_start_date_time: '2018-09-24T22:52:34.578Z', + production_date_time: '2018-07-19T12:01:01Z', + product_volume: '17956339', + published: true, + status: 'completed', + time_to_archive: 5.6, + time_to_process: 10.892, + updated_at: new Date(1576106371369), + 'error.Error': 'CumulusMessageAdapterExecutionError', + collectionName: 'MOD09GQ', + collectionVersion: '006', + providerName: 's3_provider', + pdrName: 'MOD09GQ_1granule_v3.PDR', + }; + + const apiFieldsList = Object.entries(queryStringParameters) + .map(([name, value]) => ({ name, value })); + const dbQueryParams = apiFieldsList.reduce((acc, queryField) => { + const queryParam = mapQueryStringFieldToDbField('granule', queryField); + return { ...acc, ...queryParam }; + }, {}); + t.deepEqual(dbQueryParams, expectedDbParameters); +}); + +test('mapQueryStringFieldToDbField correctly converts all asyncOperation api fields to db fields', (t) => { + const queryStringParameters = { + createdAt: '1591312763823', + id: '0eb8e809-8790-5409-1239-bcd9e8d28b8e', + operationType: 'Bulk Granule Delete', + taskArn: 'arn:aws:ecs:us-east-1:111111111111:task/d481e76e-f5fc-9c1c-2411-fa13779b111a', + status: 'SUCCEEDED', + timestamp: '1591384094512', + }; + + const expectedDbParameters = { + created_at: new Date(1591312763823), + id: '0eb8e809-8790-5409-1239-bcd9e8d28b8e', + operation_type: 'Bulk Granule Delete', + task_arn: 'arn:aws:ecs:us-east-1:111111111111:task/d481e76e-f5fc-9c1c-2411-fa13779b111a', + status: 'SUCCEEDED', + updated_at: new Date(1591384094512), + }; + + const apiFieldsList = Object.entries(queryStringParameters) + .map(([name, value]) => ({ name, value })); + const dbQueryParams = apiFieldsList.reduce((acc, queryField) => { + const queryParam = mapQueryStringFieldToDbField('asyncOperation', queryField); + return { ...acc, ...queryParam }; + }, {}); + t.deepEqual(dbQueryParams, expectedDbParameters); +}); + +test('mapQueryStringFieldToDbField correctly converts all collection api fields to db fields', (t) => { + const queryStringParameters = { + createdAt: '1591312763823', + name: 'MOD11A1', + version: '006', + updatedAt: 1591384094512, + }; + + const expectedDbParameters = { + created_at: new Date(1591312763823), + name: 'MOD11A1', + version: '006', + updated_at: new Date(1591384094512), + }; + + const apiFieldsList = Object.entries(queryStringParameters) + .map(([name, value]) => ({ name, value })); + const dbQueryParams = apiFieldsList.reduce((acc, queryField) => { + const queryParam = mapQueryStringFieldToDbField('collection', queryField); + return { ...acc, ...queryParam }; + }, {}); + t.deepEqual(dbQueryParams, expectedDbParameters); +}); + +test('mapQueryStringFieldToDbField correctly converts all execution api fields to db fields', (t) => { + const queryStringParameters = { + arn: 'https://example.com/arn', + createdAt: '1591312763823', + execution: 'https://example.com', + status: 'completed', + updatedAt: 1591384094512, + }; + + const expectedDbParameters = { + arn: 'https://example.com/arn', + created_at: new Date(1591312763823), + url: 'https://example.com', + status: 'completed', + updated_at: new Date(1591384094512), + }; + + const apiFieldsList = Object.entries(queryStringParameters) + .map(([name, value]) => ({ name, value })); + const dbQueryParams = apiFieldsList.reduce((acc, queryField) => { + const queryParam = mapQueryStringFieldToDbField('execution', queryField); + return { ...acc, ...queryParam }; + }, {}); + t.deepEqual(dbQueryParams, expectedDbParameters); +}); + +test('mapQueryStringFieldToDbField correctly converts all pdr api fields to db fields', (t) => { + const queryStringParameters = { + createdAt: '1591312763823', + pdrName: 'fakePdrName', + status: 'completed', + updatedAt: 1591384094512, + }; + + const expectedDbParameters = { + created_at: new Date(1591312763823), + name: 'fakePdrName', + status: 'completed', + updated_at: new Date(1591384094512), + }; + + const apiFieldsList = Object.entries(queryStringParameters) + .map(([name, value]) => ({ name, value })); + const dbQueryParams = apiFieldsList.reduce((acc, queryField) => { + const queryParam = mapQueryStringFieldToDbField('pdr', queryField); + return { ...acc, ...queryParam }; + }, {}); + t.deepEqual(dbQueryParams, expectedDbParameters); +}); + +test('mapQueryStringFieldToDbField correctly converts all provider api fields to db fields', (t) => { + const queryStringParameters = { + createdAt: '1591312763823', + id: 'fakeProviderId', + updatedAt: 1591384094512, + }; + + const expectedDbParameters = { + created_at: new Date(1591312763823), + name: 'fakeProviderId', + updated_at: new Date(1591384094512), + }; + + const apiFieldsList = Object.entries(queryStringParameters) + .map(([name, value]) => ({ name, value })); + const dbQueryParams = apiFieldsList.reduce((acc, queryField) => { + const queryParam = mapQueryStringFieldToDbField('provider', queryField); + return { ...acc, ...queryParam }; + }, {}); + t.deepEqual(dbQueryParams, expectedDbParameters); +}); + +test('mapQueryStringFieldToDbField correctly converts all rule api fields to db fields', (t) => { + const queryStringParameters = { + createdAt: '1591312763823', + name: 'fakePdrName', + state: 'DISABLED', + updatedAt: 1591384094512, + }; + + const expectedDbParameters = { + created_at: new Date(1591312763823), + name: 'fakePdrName', + enabled: false, + updated_at: new Date(1591384094512), + }; + + const apiFieldsList = Object.entries(queryStringParameters) + .map(([name, value]) => ({ name, value })); + const dbQueryParams = apiFieldsList.reduce((acc, queryField) => { + const queryParam = mapQueryStringFieldToDbField('rule', queryField); + return { ...acc, ...queryParam }; + }, {}); + t.deepEqual(dbQueryParams, expectedDbParameters); +}); diff --git a/packages/db/tests/search/test-queries.js b/packages/db/tests/search/test-queries.js new file mode 100644 index 00000000000..4de313d81d0 --- /dev/null +++ b/packages/db/tests/search/test-queries.js @@ -0,0 +1,38 @@ +const test = require('ava'); +const { + convertQueryStringToDbQueryParameters, +} = require('../../dist/search/queries'); + +test('convertQueryStringToDbQueryParameters correctly converts api query string parameters to db query parameters', (t) => { + const queryStringParameters = { + fields: 'granuleId,collectionId,status,updatedAt', + infix: 'A1657416', + limit: 20, + page: 3, + prefix: 'MO', + published: 'true', + status: 'completed', + 'error.Error': 'CumulusMessageAdapterExecutionError', + collectionId: 'MOD09GQ___006', + nonExistingField: 'nonExistingFieldValue', + }; + + const expectedDbQueryParameters = { + fields: ['granuleId', 'collectionId', 'status', 'updatedAt'], + infix: 'A1657416', + limit: 20, + offset: 40, + page: 3, + prefix: 'MO', + term: { + collectionName: 'MOD09GQ', + collectionVersion: '006', + published: true, + status: 'completed', + 'error.Error': 'CumulusMessageAdapterExecutionError', + }, + }; + + const dbQueryParams = convertQueryStringToDbQueryParameters('granule', queryStringParameters); + t.deepEqual(dbQueryParams, expectedDbQueryParameters); +});