From aaebbf79f6365e86e7f5a0495115a1e84c3f322d Mon Sep 17 00:00:00 2001 From: He1DAr Date: Tue, 1 Oct 2024 16:47:55 -0400 Subject: [PATCH] feat: tx list function name fuzzy search --- ...50_idx-contract-call-function-name-trgm.js | 29 ++++++++ src/api/routes/tx.ts | 12 ++++ src/datastore/pg-store.ts | 24 +++++++ tests/api/tx.test.ts | 70 +++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 migrations/1728654359950_idx-contract-call-function-name-trgm.js diff --git a/migrations/1728654359950_idx-contract-call-function-name-trgm.js b/migrations/1728654359950_idx-contract-call-function-name-trgm.js new file mode 100644 index 000000000..f099383a8 --- /dev/null +++ b/migrations/1728654359950_idx-contract-call-function-name-trgm.js @@ -0,0 +1,29 @@ +/** @param { import("node-pg-migrate").MigrationBuilder } pgm */ +exports.up = pgm => { + pgm.sql(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_available_extensions + WHERE name = 'pg_trgm' + ) THEN + CREATE EXTENSION IF NOT EXISTS pg_trgm; + + CREATE INDEX IF NOT EXISTS idx_contract_call_function_name_trgm + ON txs + USING gin (contract_call_function_name gin_trgm_ops); + END IF; + END + $$; + `); +}; + +/** @param { import("node-pg-migrate").MigrationBuilder } pgm */ +exports.down = pgm => { + pgm.sql(` + DROP INDEX IF EXISTS idx_contract_call_function_name_trgm; + `); + + pgm.sql('DROP EXTENSION IF EXISTS pg_trgm;'); +}; diff --git a/src/api/routes/tx.ts b/src/api/routes/tx.ts index fd43b87be..4be441d87 100644 --- a/src/api/routes/tx.ts +++ b/src/api/routes/tx.ts @@ -113,6 +113,12 @@ export const TxRoutes: FastifyPluginAsync< examples: [1706745599], }) ), + search_term: Type.Optional( + Type.String({ + description: 'Option to search for transactions by a search term', + examples: ['swap'], + }) + ), contract_id: Type.Optional( Type.String({ description: 'Option to filter results by contract ID', @@ -178,6 +184,11 @@ export const TxRoutes: FastifyPluginAsync< contractId = req.query.contract_id; } + let searchTerm: string | undefined; + if (typeof req.query.search_term === 'string') { + searchTerm = req.query.search_term; + } + const { results: txResults, total } = await fastify.db.getTxList({ offset, limit, @@ -188,6 +199,7 @@ export const TxRoutes: FastifyPluginAsync< startTime: req.query.start_time, endTime: req.query.end_time, contractId, + searchTerm, functionName: req.query.function_name, nonce: req.query.nonce, order: req.query.order, diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index c1175b7cc..ec3df73ae 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -101,6 +101,8 @@ import { parseBlockParam } from '../api/routes/v2/schemas'; export const MIGRATIONS_DIR = path.join(REPO_DIR, 'migrations'); +const TRGM_SIMILARITY_THRESHOLD = 0.3; + /** * This is the main interface between the API and the Postgres database. It contains all methods that * query the DB in search for blockchain data to be returned via endpoints or WebSockets/Socket.IO. @@ -1416,6 +1418,7 @@ export class PgStore extends BasePgStore { startTime, endTime, contractId, + searchTerm, functionName, nonce, order, @@ -1430,6 +1433,7 @@ export class PgStore extends BasePgStore { startTime?: number; endTime?: number; contractId?: string; + searchTerm?: string; functionName?: string; nonce?: number; order?: 'desc' | 'asc'; @@ -1468,6 +1472,23 @@ export class PgStore extends BasePgStore { const contractIdFilterSql = contractId ? sql`AND contract_call_contract_id = ${contractId}` : sql``; + + const searchTermFilterSql = searchTerm + ? sql` + AND ( + CASE + WHEN EXISTS ( + SELECT 1 + FROM pg_extension + WHERE extname = 'pg_trgm' + ) + THEN similarity(contract_call_function_name, ${searchTerm}) > ${TRGM_SIMILARITY_THRESHOLD} + ELSE contract_call_function_name ILIKE '%' || ${searchTerm} || '%' + END + ) + ` + : sql``; + const contractFuncFilterSql = functionName ? sql`AND contract_call_function_name = ${functionName}` : sql``; @@ -1479,6 +1500,7 @@ export class PgStore extends BasePgStore { !startTime && !endTime && !contractId && + !searchTerm && !functionName && !nonce; @@ -1497,6 +1519,7 @@ export class PgStore extends BasePgStore { ${startTimeFilterSql} ${endTimeFilterSql} ${contractIdFilterSql} + ${searchTermFilterSql} ${contractFuncFilterSql} ${nonceFilterSql} `; @@ -1511,6 +1534,7 @@ export class PgStore extends BasePgStore { ${startTimeFilterSql} ${endTimeFilterSql} ${contractIdFilterSql} + ${searchTermFilterSql} ${contractFuncFilterSql} ${nonceFilterSql} ${orderBySql} diff --git a/tests/api/tx.test.ts b/tests/api/tx.test.ts index e9d8122e5..6e65f9354 100644 --- a/tests/api/tx.test.ts +++ b/tests/api/tx.test.ts @@ -2326,6 +2326,76 @@ describe('tx tests', () => { ); }); + test('tx list - filter by searchTerm using trigram', async () => { + const transferTokenTx = { + tx_id: '0x1111', + contract_call_function_name: 'transferToken', + }; + + const stakeTokenTx = { + tx_id: '0x2222', + contract_call_function_name: 'stakeToken', + }; + + const burnTokenTx = { + tx_id: '0x3333', + contract_call_function_name: 'burnToken', + }; + + const block1 = new TestBlockBuilder({ block_height: 1, index_block_hash: '0x01' }) + .addTx(transferTokenTx) + .build(); + await db.update(block1); + + const block2 = new TestBlockBuilder({ + block_height: 2, + index_block_hash: '0x02', + parent_block_hash: block1.block.block_hash, + parent_index_block_hash: block1.block.index_block_hash, + }) + .addTx(stakeTokenTx) + .addTx(burnTokenTx) + .build(); + await db.update(block2); + + const searchTerm = 'transfer'; + + const txsReq = await supertest(api.server).get(`/extended/v1/tx?search_term=${searchTerm}`); + expect(txsReq.status).toBe(200); + expect(txsReq.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: transferTokenTx.tx_id, + }), + ], + }) + ); + + const broadSearchTerm = 'token'; + + const txsReqBroad = await supertest(api.server).get( + `/extended/v1/tx?search_term=${broadSearchTerm}` + ); + expect(txsReqBroad.status).toBe(200); + + expect(txsReqBroad.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: burnTokenTx.tx_id, + }), + expect.objectContaining({ + tx_id: stakeTokenTx.tx_id, + }), + expect.objectContaining({ + tx_id: transferTokenTx.tx_id, + }), + ], + }) + ); + }); + test('tx list - filter by contract id/name', async () => { const testContractAddr = 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y.hello-world'; const testContractFnName = 'test-contract-fn';