Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CUMULUS-3692: Update granules List endpoints to query postgres for basic queries #3637

Merged
merged 17 commits into from
May 8, 2024
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).

## Unreleased

### Replace ElasticSearch Phase 1

- **CUMULUS-3692**
- Update granules List endpoints to query postgres for basic queries


### Migration Notes

#### CUMULUS-3433 Update to node.js v20
Expand Down
12 changes: 11 additions & 1 deletion packages/api/endpoints/granules.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const { v4: uuidv4 } = require('uuid');
const Logger = require('@cumulus/logger');
const { deconstructCollectionId } = require('@cumulus/message/Collections');
const { RecordDoesNotExist } = require('@cumulus/errors');
const { GranuleSearch } = require('@cumulus/db');

const {
CollectionPgModel,
Expand Down Expand Up @@ -101,6 +102,7 @@ function _createNewGranuleDateValue() {
* @returns {Promise<Object>} the promise of express response object
*/
async function list(req, res) {
log.trace(`list query ${JSON.stringify(req.query)}`);
const { getRecoveryStatus, ...queryStringParameters } = req.query;

let es;
Expand All @@ -113,7 +115,15 @@ async function list(req, res) {
} else {
es = new Search({ queryStringParameters }, 'granule', process.env.ES_INDEX);
}
const result = await es.query();
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();
}
if (getRecoveryStatus === 'true') {
return res.send(await addOrcaRecoveryStatus(result));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ test.after.always(async (t) => {
await t.context.esClient.client.indices.delete({ index: t.context.esIndex });
});

test.serial('CUMULUS-2930 /GET granules allows searching past 10K results windows with searchContext', async (t) => {
// TODO postgres query doesn't return searchContext
test.serial.skip('CUMULUS-2930 /GET granules allows searching past 10K results windows with searchContext', async (t) => {
const numGranules = 12 * 1000;

// create granules in batches of 1000
Expand Down
48 changes: 46 additions & 2 deletions packages/api/tests/endpoints/test-granules.js
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,8 @@ test.after.always(async (t) => {
await cleanupTestIndex(t.context);
});

test.serial('default lists and paginates correctly with search_after', async (t) => {
// TODO postgres query doesn't return searchContext
test.serial.skip('default lists and paginates correctly with search_after', async (t) => {
const granuleIds = t.context.fakePGGranules.map((i) => i.granule_id);
const response = await request(app)
.get('/granules')
Expand Down Expand Up @@ -446,6 +447,48 @@ test.serial('default lists and paginates correctly with search_after', async (t)
t.not(meta.searchContext === newMeta.searchContext);
});

test.serial('default lists and paginates correctly', async (t) => {
const granuleIds = t.context.fakePGGranules.map((i) => i.granule_id);
const response = await request(app)
.get('/granules')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${jwtAuthToken}`)
.expect(200);

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.count, 4);
results.forEach((r) => {
t.true(granuleIds.includes(r.granuleId));
});
// default paginates correctly with search_after
const firstResponse = await request(app)
.get('/granules?limit=1')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${jwtAuthToken}`)
.expect(200);

const { meta: firstMeta, results: firstResults } = firstResponse.body;
t.is(firstResults.length, 1);
t.is(firstMeta.page, 1);

const newResponse = await request(app)
.get('/granules?limit=1&page=2')
.set('Accept', 'application/json')
.set('Authorization', `Bearer ${jwtAuthToken}`)
.expect(200);

const { meta: newMeta, results: newResults } = newResponse.body;
t.is(newResults.length, 1);
t.is(newMeta.page, 2);

t.true(granuleIds.includes(results[0].granuleId));
t.true(granuleIds.includes(newResults[0].granuleId));
t.not(results[0].granuleId, newResults[0].granuleId);
});

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')
Expand Down Expand Up @@ -3846,7 +3889,8 @@ test.serial('PUT returns 404 if collection is not part of URI', async (t) => {
t.is(response.statusCode, 404);
});

test.serial('default paginates correctly with search_after', async (t) => {
// TODO postgres query doesn't return searchContext
test.serial.skip('default paginates correctly with search_after', async (t) => {
const response = await request(app)
.get('/granules?limit=1')
.set('Accept', 'application/json')
Expand Down
6 changes: 6 additions & 0 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ export {
export {
QuerySearchClient,
} from './lib/QuerySearchClient';
export {
BaseSearch,
} from './search/BaseSearch';
export {
GranuleSearch,
} from './search/GranuleSearch';

export { AsyncOperationPgModel } from './models/async_operation';
export { BasePgModel } from './models/base';
Expand Down
128 changes: 128 additions & 0 deletions packages/db/src/search/BaseSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { Knex } from 'knex';
import Logger from '@cumulus/logger';
import { getKnexClient } from '../connection';
import { BaseRecord } from '../types/base';
import { DbQueryParameters, QueryEvent, QueryStringParameters } from '../types/search';

const log = new Logger({ sender: '@cumulus/db/BaseSearch' });

export interface Meta {
name: string,
stack?: string,
table?: string,
limit?: number,
page?: number,
count?: number,
}

/**
* Class to build and execute db search query
*/
class BaseSearch {
readonly type?: string;
readonly queryStringParameters: QueryStringParameters;
// parsed from queryStringParameters for query build
dbQueryParameters: DbQueryParameters = {};

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.offset = (this.dbQueryParameters.page - 1)
* this.dbQueryParameters.limit;
}

/**
* build the search query
*
* @param knex - DB client
* @returns queries for getting count and search result
*/
private _buildSearch(knex: Knex)
: {
countQuery: Knex.QueryBuilder,
searchQuery: Knex.QueryBuilder,
} {
const { countQuery, searchQuery } = this.buildBasicQuery(knex);
const updatedQuery = searchQuery.modify((queryBuilder) => {
if (this.dbQueryParameters.limit) queryBuilder.limit(this.dbQueryParameters.limit);
if (this.dbQueryParameters.offset) queryBuilder.offset(this.dbQueryParameters.offset);
});
return { countQuery, searchQuery: updatedQuery };
}

/**
* metadata template for query result
*
* @returns metadata template
*/
private _metaTemplate(): Meta {
return {
name: 'cumulus-api',
stack: process.env.stackName,
table: this.type,
};
}

/**
* build basic query
*
* @param knex - DB client
* @throws - function is not implemented
*/
protected buildBasicQuery(knex: Knex): {
countQuery: Knex.QueryBuilder,
searchQuery: Knex.QueryBuilder,
} {
log.debug(`buildBasicQuery is not implemented ${knex.constructor.name}`);
throw new Error('buildBasicQuery is not implemented');
}

/**
* Translate postgres records to api records
*
* @param pgRecords - postgres records returned from query
* @throws - function is not implemented
*/
protected translatePostgresRecordsToApiRecords(pgRecords: BaseRecord[]) {
log.error(`translatePostgresRecordsToApiRecords is not implemented ${pgRecords[0]}`);
throw new Error('translatePostgresRecordsToApiRecords is not implemented');
}

/**
* build and execute search query
*
* @param testKnex - knex for testing
* @returns search result
*/
async query(testKnex: Knex | undefined) {
const knex = testKnex ?? await getKnexClient();
const { countQuery, searchQuery } = this._buildSearch(knex);
try {
const countResult = await countQuery;
const meta = this._metaTemplate();
meta.limit = this.dbQueryParameters.limit;
meta.page = this.dbQueryParameters.page;
meta.count = Number(countResult[0]?.count ?? 0);

const pgRecords = await searchQuery;
const apiRecords = this.translatePostgresRecordsToApiRecords(pgRecords);

return {
meta,
results: apiRecords,
};
} catch (error) {
return error;
}
}
}

export { BaseSearch };
93 changes: 93 additions & 0 deletions packages/db/src/search/GranuleSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Knex } from 'knex';

import { ApiGranuleRecord } from '@cumulus/types/api/granules';
import Logger from '@cumulus/logger';

import { BaseRecord } from '../types/base';
import { BaseSearch } from './BaseSearch';
import { PostgresGranuleRecord } from '../types/granule';
import { QueryEvent } from '../types/search';

import { TableNames } from '../tables';
import { translatePostgresGranuleToApiGranuleWithoutDbQuery } from '../translate/granules';

const log = new Logger({ sender: '@cumulus/db/BaseSearch' });

export interface GranuleRecord extends BaseRecord, PostgresGranuleRecord {
cumulus_id: number,
updated_at: Date,
collection_cumulus_id: number,
collectionName: string,
collectionVersion: string,
pdr_cumulus_id: number,
pdrName?: string,
provider_cumulus_id?: number,
providerName?: string,
}

/**
* Class to build and execute db search query for granules
*/
export class GranuleSearch extends BaseSearch {
constructor(event: QueryEvent) {
super(event, 'granule');
}

/**
* build basic query
*
* @param knex - DB client
* @returns queries for getting count and search result
*/
protected buildBasicQuery(knex: Knex)
: {
countQuery: Knex.QueryBuilder,
searchQuery: Knex.QueryBuilder,
} {
const {
granules: granulesTable,
collections: collectionsTable,
providers: providersTable,
pdrs: pdrsTable,
} = TableNames;
const countQuery = knex(granulesTable)
.count(`${granulesTable}.cumulus_id`);

const searchQuery = knex(granulesTable)
.select(`${granulesTable}.*`)
.select({
providerName: `${providersTable}.name`,
collectionName: `${collectionsTable}.name`,
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`);
return { countQuery, searchQuery };
}

/**
* Translate postgres records to api records
*
* @param pgRecords - postgres records returned from query
* @returns translated api records
*/
protected translatePostgresRecordsToApiRecords(pgRecords: GranuleRecord[]) : ApiGranuleRecord[] {
log.debug(`translatePostgresRecordsToApiRecords number of records ${pgRecords.length} `);
const apiRecords = pgRecords.map((item: GranuleRecord) => {
const granulePgRecord = item;
const collectionPgRecord = {
cumulus_id: item.collection_cumulus_id,
name: item.collectionName,
version: item.collectionVersion,
};
const pdr = item.pdrName ? { name: item.pdrName } : undefined;
const providerPgRecord = item.providerName ? { name: item.providerName } : undefined;
return translatePostgresGranuleToApiGranuleWithoutDbQuery({
granulePgRecord, collectionPgRecord, pdr, providerPgRecord,
});
});
return apiRecords;
}
}
Loading