From 8389d3971048368c114341dd071ed733eccfb9b3 Mon Sep 17 00:00:00 2001 From: Yoshihito Aso Date: Fri, 21 Aug 2026 15:22:37 +0900 Subject: [PATCH] Optimize transfer history filtering and indexes --- app/api/routers/token.py | 81 +++++-------- app/model/db/idx_transfer.py | 26 ++++- app/model/schema/token.py | 26 +++-- docs/ibet_wallet_api.yaml | 30 ++--- .../c7b40745b8d9_v26_9_0_feature_1858.py | 106 ++++++++++++++++++ ...fer_history_ListAllTransferHistory_test.py | 10 +- ...r_history_ListTokenTransferHistory_test.py | 8 +- ...history_SearchTokenTransferHistory_test.py | 8 +- 8 files changed, 201 insertions(+), 94 deletions(-) create mode 100644 migrations/versions/c7b40745b8d9_v26_9_0_feature_1858.py diff --git a/app/api/routers/token.py b/app/api/routers/token.py index 15cc187f7..c1b146631 100644 --- a/app/api/routers/token.py +++ b/app/api/routers/token.py @@ -941,20 +941,16 @@ async def list_all_transfer_histories( """ # Base query - from_address_tag = aliased(AccountTag) - to_address_tag = aliased(AccountTag) - stmt = ( - select(IDXTransfer) - .outerjoin( + stmt = select(IDXTransfer) + if request_query.account_tag is not None: + from_address_tag = aliased(AccountTag) + to_address_tag = aliased(AccountTag) + stmt = stmt.outerjoin( from_address_tag, IDXTransfer.from_address == from_address_tag.account_address, - ) - .outerjoin( + ).outerjoin( to_address_tag, IDXTransfer.to_address == to_address_tag.account_address ) - .order_by(IDXTransfer.id) - ) - if request_query.account_tag is not None: stmt = stmt.where( or_( from_address_tag.account_tag == request_query.account_tag, @@ -963,7 +959,7 @@ async def list_all_transfer_histories( ) total = await async_session.scalar( - stmt.with_only_columns(func.count()).order_by(None) + stmt.with_only_columns(func.count(), maintain_column_froms=True).order_by(None) ) # Filter @@ -979,18 +975,12 @@ async def list_all_transfer_histories( ) if request_query.transaction_hash is not None: stmt = stmt.where( - IDXTransfer.transaction_hash.like( - "%" + request_query.transaction_hash + "%" - ) + IDXTransfer.transaction_hash == request_query.transaction_hash ) if request_query.from_address is not None: - stmt = stmt.where( - IDXTransfer.from_address.like("%" + request_query.from_address + "%") - ) + stmt = stmt.where(IDXTransfer.from_address == request_query.from_address) if request_query.to_address is not None: - stmt = stmt.where( - IDXTransfer.to_address.like("%" + request_query.to_address + "%") - ) + stmt = stmt.where(IDXTransfer.to_address == request_query.to_address) if request_query.created_from is not None: stmt = stmt.where(IDXTransfer.created >= request_query.created_from) if request_query.created_to is not None: @@ -1005,7 +995,7 @@ async def list_all_transfer_histories( stmt = stmt.where(IDXTransfer.value <= request_query.value) count = await async_session.scalar( - stmt.with_only_columns(func.count()).order_by(None) + stmt.with_only_columns(func.count(), maintain_column_froms=True).order_by(None) ) # Sort @@ -1147,23 +1137,18 @@ async def list_token_transfer_histories( raise DataNotExistsError("token_address: %s" % token_address) # Base query - from_address_tag = aliased(AccountTag) - to_address_tag = aliased(AccountTag) - stmt = ( - select(IDXTransfer) - .where(IDXTransfer.token_address == token_address) - .outerjoin( + stmt = select(IDXTransfer).where(IDXTransfer.token_address == token_address) + + # Filter + if request_query.account_tag is not None: + from_address_tag = aliased(AccountTag) + to_address_tag = aliased(AccountTag) + stmt = stmt.outerjoin( from_address_tag, IDXTransfer.from_address == from_address_tag.account_address, - ) - .outerjoin( + ).outerjoin( to_address_tag, IDXTransfer.to_address == to_address_tag.account_address ) - .order_by(IDXTransfer.id) - ) - - # Filter - if request_query.account_tag is not None: stmt = stmt.where( or_( from_address_tag.account_tag == request_query.account_tag, @@ -1172,7 +1157,7 @@ async def list_token_transfer_histories( ) total = await async_session.scalar( - stmt.with_only_columns(func.count()).order_by(None) + stmt.with_only_columns(func.count(), maintain_column_froms=True).order_by(None) ) if request_query.source_event is not None: @@ -1183,18 +1168,12 @@ async def list_token_transfer_histories( ) if request_query.transaction_hash is not None: stmt = stmt.where( - IDXTransfer.transaction_hash.like( - "%" + request_query.transaction_hash + "%" - ) + IDXTransfer.transaction_hash == request_query.transaction_hash ) if request_query.from_address is not None: - stmt = stmt.where( - IDXTransfer.from_address.like("%" + request_query.from_address + "%") - ) + stmt = stmt.where(IDXTransfer.from_address == request_query.from_address) if request_query.to_address is not None: - stmt = stmt.where( - IDXTransfer.to_address.like("%" + request_query.to_address + "%") - ) + stmt = stmt.where(IDXTransfer.to_address == request_query.to_address) if request_query.created_from is not None: stmt = stmt.where(IDXTransfer.created >= request_query.created_from) if request_query.created_to is not None: @@ -1209,7 +1188,7 @@ async def list_token_transfer_histories( stmt = stmt.where(IDXTransfer.value <= request_query.value) count = await async_session.scalar( - stmt.with_only_columns(func.count()).order_by(None) + stmt.with_only_columns(func.count(), maintain_column_froms=True).order_by(None) ) # Sort @@ -1370,7 +1349,7 @@ async def search_transfer_histories( ) ) total = await async_session.scalar( - stmt.with_only_columns(func.count()).order_by(None) + stmt.with_only_columns(func.count(), maintain_column_froms=True).order_by(None) ) if data.source_event is not None: @@ -1378,13 +1357,11 @@ async def search_transfer_histories( if data.data is not None: stmt = stmt.where(cast(IDXTransfer.data, String).like("%" + data.data + "%")) if data.transaction_hash is not None: - stmt = stmt.where( - IDXTransfer.transaction_hash.like("%" + data.transaction_hash + "%") - ) + stmt = stmt.where(IDXTransfer.transaction_hash == data.transaction_hash) if data.from_address is not None: - stmt = stmt.where(IDXTransfer.from_address.like("%" + data.from_address + "%")) + stmt = stmt.where(IDXTransfer.from_address == data.from_address) if data.to_address is not None: - stmt = stmt.where(IDXTransfer.to_address.like("%" + data.to_address + "%")) + stmt = stmt.where(IDXTransfer.to_address == data.to_address) if data.created_from is not None: stmt = stmt.where( IDXTransfer.created >= data.created_from.astimezone(timezone.utc) @@ -1403,7 +1380,7 @@ async def search_transfer_histories( stmt = stmt.where(IDXTransfer.value <= data.value) count = await async_session.scalar( - stmt.with_only_columns(func.count()).order_by(None) + stmt.with_only_columns(func.count(), maintain_column_froms=True).order_by(None) ) sort_item = data.sort_item diff --git a/app/model/db/idx_transfer.py b/app/model/db/idx_transfer.py index 5d6c80b9b..3c5f02b83 100644 --- a/app/model/db/idx_transfer.py +++ b/app/model/db/idx_transfer.py @@ -23,7 +23,7 @@ from zoneinfo import ZoneInfo from pydantic import BaseModel -from sqlalchemy import JSON, BigInteger, String +from sqlalchemy import JSON, BigInteger, Index, String from sqlalchemy.orm import Mapped, mapped_column from app.config import TZ @@ -55,6 +55,30 @@ class IDXTransfer(Base): """Token Transfer Events (INDEX)""" __tablename__ = "transfer" + __table_args__ = ( + Index("ix_transfer_token_address_id", "token_address", "id"), + Index("ix_transfer_transaction_hash_id", "transaction_hash", "id"), + Index("ix_transfer_from_address_id", "from_address", "id"), + Index("ix_transfer_to_address_id", "to_address", "id"), + Index( + "ix_transfer_token_address_transaction_hash_id", + "token_address", + "transaction_hash", + "id", + ), + Index( + "ix_transfer_token_address_from_address_id", + "token_address", + "from_address", + "id", + ), + Index( + "ix_transfer_token_address_to_address_id", + "token_address", + "to_address", + "id", + ), + ) # Sequence Id id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) diff --git a/app/model/schema/token.py b/app/model/schema/token.py index 8db65ef8b..3b3a983c1 100644 --- a/app/model/schema/token.py +++ b/app/model/schema/token.py @@ -178,9 +178,11 @@ class ListAllTransferHistoryQuery(BasePaginationQuery): ) data: Optional[str] = Field(None, description="source event data") token_address: Optional[str] = Field(None, description="token address") - transaction_hash: Optional[str] = Field(None, description="transaction hash") - from_address: Optional[str] = Field(None, description="from address") - to_address: Optional[str] = Field(None, description="to address") + transaction_hash: Optional[str] = Field( + None, description="transaction hash (exact match)" + ) + from_address: Optional[str] = Field(None, description="from address (exact match)") + to_address: Optional[str] = Field(None, description="to address (exact match)") value: Optional[int] = Field(None, description="value") value_operator: Optional[ValueOperator] = Field( ValueOperator.EQUAL, @@ -202,9 +204,11 @@ class ListTokenTransferHistoryQuery(BasePaginationQuery): None, description="source event of transfer" ) data: Optional[str] = Field(None, description="source event data") - transaction_hash: Optional[str] = Field(None, description="transaction hash") - from_address: Optional[str] = Field(None, description="from address") - to_address: Optional[str] = Field(None, description="to address") + transaction_hash: Optional[str] = Field( + None, description="transaction hash (exact match)" + ) + from_address: Optional[str] = Field(None, description="from address (exact match)") + to_address: Optional[str] = Field(None, description="to address (exact match)") value: Optional[int] = Field(None, description="value") value_operator: Optional[ValueOperator] = Field( ValueOperator.EQUAL, @@ -241,10 +245,14 @@ class SearchTransferHistoryRequest(BaseModel): ) data: Optional[str] = Field(default=None, description="source event data") transaction_hash: Optional[str] = Field( - default=None, description="transaction hash" + default=None, description="transaction hash (exact match)" + ) + from_address: Optional[str] = Field( + default=None, description="from address (exact match)" + ) + to_address: Optional[str] = Field( + default=None, description="to address (exact match)" ) - from_address: Optional[str] = Field(default=None, description="from address") - to_address: Optional[str] = Field(default=None, description="to address") created_from: Optional[datetime] = Field( default=None, description="created from datetime" ) diff --git a/docs/ibet_wallet_api.yaml b/docs/ibet_wallet_api.yaml index 7126a43c4..b3194b299 100644 --- a/docs/ibet_wallet_api.yaml +++ b/docs/ibet_wallet_api.yaml @@ -2914,9 +2914,9 @@ paths: anyOf: - type: string - type: 'null' - description: transaction hash + description: transaction hash (exact match) title: Transaction Hash - description: transaction hash + description: transaction hash (exact match) - name: from_address in: query required: false @@ -2924,9 +2924,9 @@ paths: anyOf: - type: string - type: 'null' - description: from address + description: from address (exact match) title: From Address - description: from address + description: from address (exact match) - name: to_address in: query required: false @@ -2934,9 +2934,9 @@ paths: anyOf: - type: string - type: 'null' - description: to address + description: to address (exact match) title: To Address - description: to address + description: to address (exact match) - name: value in: query required: false @@ -3079,9 +3079,9 @@ paths: anyOf: - type: string - type: 'null' - description: transaction hash + description: transaction hash (exact match) title: Transaction Hash - description: transaction hash + description: transaction hash (exact match) - name: from_address in: query required: false @@ -3089,9 +3089,9 @@ paths: anyOf: - type: string - type: 'null' - description: from address + description: from address (exact match) title: From Address - description: from address + description: from address (exact match) - name: to_address in: query required: false @@ -3099,9 +3099,9 @@ paths: anyOf: - type: string - type: 'null' - description: to address + description: to address (exact match) title: To Address - description: to address + description: to address (exact match) - name: value in: query required: false @@ -8889,19 +8889,19 @@ components: - type: string - type: 'null' title: Transaction Hash - description: transaction hash + description: transaction hash (exact match) from_address: anyOf: - type: string - type: 'null' title: From Address - description: from address + description: from address (exact match) to_address: anyOf: - type: string - type: 'null' title: To Address - description: to address + description: to address (exact match) created_from: anyOf: - type: string diff --git a/migrations/versions/c7b40745b8d9_v26_9_0_feature_1858.py b/migrations/versions/c7b40745b8d9_v26_9_0_feature_1858.py new file mode 100644 index 000000000..4d5932b31 --- /dev/null +++ b/migrations/versions/c7b40745b8d9_v26_9_0_feature_1858.py @@ -0,0 +1,106 @@ +"""v26_9_0_feature_1858 + +Revision ID: c7b40745b8d9 +Revises: 85d3431e23dd +Create Date: 2026-08-21 00:00:00.000000 +""" + +from alembic import op + +from app.database import get_db_schema + +# revision identifiers, used by Alembic. +revision = "c7b40745b8d9" +down_revision = "85d3431e23dd" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_index( + op.f("ix_transfer_token_address_id"), + "transfer", + ["token_address", "id"], + unique=False, + schema=get_db_schema(), + ) + op.create_index( + op.f("ix_transfer_transaction_hash_id"), + "transfer", + ["transaction_hash", "id"], + unique=False, + schema=get_db_schema(), + ) + op.create_index( + op.f("ix_transfer_from_address_id"), + "transfer", + ["from_address", "id"], + unique=False, + schema=get_db_schema(), + ) + op.create_index( + op.f("ix_transfer_to_address_id"), + "transfer", + ["to_address", "id"], + unique=False, + schema=get_db_schema(), + ) + op.create_index( + op.f("ix_transfer_token_address_transaction_hash_id"), + "transfer", + ["token_address", "transaction_hash", "id"], + unique=False, + schema=get_db_schema(), + ) + op.create_index( + op.f("ix_transfer_token_address_from_address_id"), + "transfer", + ["token_address", "from_address", "id"], + unique=False, + schema=get_db_schema(), + ) + op.create_index( + op.f("ix_transfer_token_address_to_address_id"), + "transfer", + ["token_address", "to_address", "id"], + unique=False, + schema=get_db_schema(), + ) + + +def downgrade(): + op.drop_index( + op.f("ix_transfer_token_address_to_address_id"), + table_name="transfer", + schema=get_db_schema(), + ) + op.drop_index( + op.f("ix_transfer_token_address_from_address_id"), + table_name="transfer", + schema=get_db_schema(), + ) + op.drop_index( + op.f("ix_transfer_token_address_transaction_hash_id"), + table_name="transfer", + schema=get_db_schema(), + ) + op.drop_index( + op.f("ix_transfer_to_address_id"), + table_name="transfer", + schema=get_db_schema(), + ) + op.drop_index( + op.f("ix_transfer_from_address_id"), + table_name="transfer", + schema=get_db_schema(), + ) + op.drop_index( + op.f("ix_transfer_transaction_hash_id"), + table_name="transfer", + schema=get_db_schema(), + ) + op.drop_index( + op.f("ix_transfer_token_address_id"), + table_name="transfer", + schema=get_db_schema(), + ) diff --git a/tests/app/token_transfer_history_ListAllTransferHistory_test.py b/tests/app/token_transfer_history_ListAllTransferHistory_test.py index 4e8cf535a..d82335212 100644 --- a/tests/app/token_transfer_history_ListAllTransferHistory_test.py +++ b/tests/app/token_transfer_history_ListAllTransferHistory_test.py @@ -809,7 +809,7 @@ def test_normal_4_6(self, client: TestClient, session: Session): # Call the target API resp = client.get( - self.apiurl, params={"transaction_hash": self.transaction_hash[0:5]} + self.apiurl, params={"transaction_hash": self.transaction_hash} ) # Assertion @@ -875,9 +875,7 @@ def test_normal_4_7(self, client: TestClient, session: Session): session.commit() # Call the target API - resp = client.get( - self.apiurl, params={"from_address": self.account_address_1[0:5]} - ) + resp = client.get(self.apiurl, params={"from_address": self.account_address_1}) # Assertion assert resp.status_code == 200 @@ -942,9 +940,7 @@ def test_normal_4_8(self, client: TestClient, session: Session): session.commit() # Call the target API - resp = client.get( - self.apiurl, params={"to_address": self.account_address_2[0:5]} - ) + resp = client.get(self.apiurl, params={"to_address": self.account_address_2}) # Assertion assert resp.status_code == 200 diff --git a/tests/app/token_transfer_history_ListTokenTransferHistory_test.py b/tests/app/token_transfer_history_ListTokenTransferHistory_test.py index 61348d110..84482373e 100644 --- a/tests/app/token_transfer_history_ListTokenTransferHistory_test.py +++ b/tests/app/token_transfer_history_ListTokenTransferHistory_test.py @@ -811,9 +811,7 @@ def test_normal_4_4(self, client: TestClient, session: Session): session.commit() apiurl = self.apiurl_base.format(contract_address=self.token_address) - resp = client.get( - apiurl, params={"transaction_hash": self.transaction_hash[0:5]} - ) + resp = client.get(apiurl, params={"transaction_hash": self.transaction_hash}) assert resp.status_code == 200 assert resp.json()["meta"] == {"code": 200, "message": "OK"} @@ -878,7 +876,7 @@ def test_normal_4_5(self, client: TestClient, session: Session): session.commit() apiurl = self.apiurl_base.format(contract_address=self.token_address) - resp = client.get(apiurl, params={"from_address": self.from_address[0:5]}) + resp = client.get(apiurl, params={"from_address": self.from_address}) assert resp.status_code == 200 assert resp.json()["meta"] == {"code": 200, "message": "OK"} @@ -943,7 +941,7 @@ def test_normal_4_6(self, client: TestClient, session: Session): session.commit() apiurl = self.apiurl_base.format(contract_address=self.token_address) - resp = client.get(apiurl, params={"to_address": self.to_address[0:5]}) + resp = client.get(apiurl, params={"to_address": self.to_address}) assert resp.status_code == 200 assert resp.json()["meta"] == {"code": 200, "message": "OK"} diff --git a/tests/app/token_transfer_history_SearchTokenTransferHistory_test.py b/tests/app/token_transfer_history_SearchTokenTransferHistory_test.py index 2235d2dc1..69c12420b 100644 --- a/tests/app/token_transfer_history_SearchTokenTransferHistory_test.py +++ b/tests/app/token_transfer_history_SearchTokenTransferHistory_test.py @@ -797,9 +797,7 @@ def test_normal_3_7(self, client: TestClient, session: Session): session.commit() apiurl = self.apiurl_base.format(contract_address=self.token_address) - resp = client.post( - apiurl, json={"transaction_hash": self.transaction_hash[0:5]} - ) + resp = client.post(apiurl, json={"transaction_hash": self.transaction_hash}) assert resp.status_code == 200 assert resp.json()["meta"] == {"code": 200, "message": "OK"} @@ -864,7 +862,7 @@ def test_normal_3_8(self, client: TestClient, session: Session): session.commit() apiurl = self.apiurl_base.format(contract_address=self.token_address) - resp = client.post(apiurl, json={"from_address": self.from_address[0:5]}) + resp = client.post(apiurl, json={"from_address": self.from_address}) assert resp.status_code == 200 assert resp.json()["meta"] == {"code": 200, "message": "OK"} @@ -929,7 +927,7 @@ def test_normal_3_9(self, client: TestClient, session: Session): session.commit() apiurl = self.apiurl_base.format(contract_address=self.token_address) - resp = client.post(apiurl, json={"to_address": self.to_address[0:5]}) + resp = client.post(apiurl, json={"to_address": self.to_address}) assert resp.status_code == 200 assert resp.json()["meta"] == {"code": 200, "message": "OK"}