Skip to content

Commit a18c00f

Browse files
authored
feat: add filters and sorting to /credit_history endpoint (#1099)
* feat: add filters and sorting to /credit_history endpoint Add has_expiration boolean filter, exclude_payment_method list filter, and configurable sort_by/sort_order with cursor pagination support. * fix: address PR review - cursor sort_order, nullable columns cleanup - Add sort_order to cursor encoding/decoding to prevent incorrect pagination when switching between ASC/DESC directions - Remove payment_method from _NULLABLE_SORT_COLUMNS (always has a value) - Add comments explaining why NULL inclusion is correct in keyset pagination - Add cursor pagination test verifying NULLs appear correctly across pages
1 parent 2d90a4f commit a18c00f

6 files changed

Lines changed: 843 additions & 94 deletions

File tree

src/aleph/db/accessors/balances.py

Lines changed: 171 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
)
1919
from aleph.toolkit.timestamp import timestamp_to_datetime, utc_now
2020
from aleph.types.db_session import DbSession
21+
from aleph.types.sort_order import SortByCreditHistory, SortOrder
2122

2223

2324
def _apply_credit_precision_multiplier(
@@ -869,6 +870,63 @@ def validate_credit_transfer_balance(
869870
return current_balance >= total_transfer_amount
870871

871872

873+
CREDIT_HISTORY_SORT_COLUMN_MAP = {
874+
SortByCreditHistory.MESSAGE_TIMESTAMP: AlephCreditHistoryDb.message_timestamp,
875+
SortByCreditHistory.EXPIRATION_DATE: AlephCreditHistoryDb.expiration_date,
876+
SortByCreditHistory.PAYMENT_METHOD: AlephCreditHistoryDb.payment_method,
877+
SortByCreditHistory.AMOUNT: AlephCreditHistoryDb.amount,
878+
SortByCreditHistory.ORIGIN: AlephCreditHistoryDb.origin,
879+
SortByCreditHistory.TX_HASH: AlephCreditHistoryDb.tx_hash,
880+
SortByCreditHistory.PROVIDER: AlephCreditHistoryDb.provider,
881+
}
882+
883+
# Columns that are nullable and need NULLS LAST handling
884+
_NULLABLE_SORT_COLUMNS = {
885+
SortByCreditHistory.EXPIRATION_DATE,
886+
SortByCreditHistory.ORIGIN,
887+
SortByCreditHistory.TX_HASH,
888+
SortByCreditHistory.PROVIDER,
889+
}
890+
891+
892+
def _apply_credit_history_filters(
893+
query: Select,
894+
tx_hash: Optional[str] = None,
895+
token: Optional[str] = None,
896+
chain: Optional[str] = None,
897+
provider: Optional[str] = None,
898+
origin: Optional[str] = None,
899+
origin_ref: Optional[str] = None,
900+
payment_method: Optional[str] = None,
901+
has_expiration: Optional[bool] = None,
902+
exclude_payment_method: Optional[List[str]] = None,
903+
) -> Select:
904+
"""Apply common filters to a credit history query."""
905+
if tx_hash is not None:
906+
query = query.where(AlephCreditHistoryDb.tx_hash == tx_hash)
907+
if token is not None:
908+
query = query.where(AlephCreditHistoryDb.token == token)
909+
if chain is not None:
910+
query = query.where(AlephCreditHistoryDb.chain == chain)
911+
if provider is not None:
912+
query = query.where(AlephCreditHistoryDb.provider == provider)
913+
if origin is not None:
914+
query = query.where(AlephCreditHistoryDb.origin == origin)
915+
if origin_ref is not None:
916+
query = query.where(AlephCreditHistoryDb.origin_ref == origin_ref)
917+
if payment_method is not None:
918+
query = query.where(AlephCreditHistoryDb.payment_method == payment_method)
919+
if has_expiration is True:
920+
query = query.where(AlephCreditHistoryDb.expiration_date.isnot(None))
921+
elif has_expiration is False:
922+
query = query.where(AlephCreditHistoryDb.expiration_date.is_(None))
923+
if exclude_payment_method:
924+
query = query.where(
925+
AlephCreditHistoryDb.payment_method.notin_(exclude_payment_method)
926+
)
927+
return query
928+
929+
872930
def get_address_credit_history(
873931
session: DbSession,
874932
address: str,
@@ -881,75 +939,122 @@ def get_address_credit_history(
881939
origin: Optional[str] = None,
882940
origin_ref: Optional[str] = None,
883941
payment_method: Optional[str] = None,
884-
after_time: Optional[dt.datetime] = None,
942+
has_expiration: Optional[bool] = None,
943+
exclude_payment_method: Optional[List[str]] = None,
944+
sort_by: SortByCreditHistory = SortByCreditHistory.MESSAGE_TIMESTAMP,
945+
sort_order: SortOrder = SortOrder.DESCENDING,
946+
after_sort_value: Optional[Any] = None,
885947
after_credit_ref: Optional[str] = None,
886948
after_credit_index: Optional[int] = None,
887949
cursor_mode: bool = False,
888950
) -> Sequence[AlephCreditHistoryDb]:
889951
"""
890-
Get paginated credit history entries for a specific address, ordered from newest to oldest.
952+
Get paginated credit history entries for a specific address.
891953
892-
Args:
893-
session: Database session
894-
address: Address to get credit history for
895-
page: Page number (starts at 1)
896-
pagination: Number of entries per page (0 for all entries)
897-
tx_hash: Filter by transaction hash
898-
token: Filter by token
899-
chain: Filter by chain
900-
provider: Filter by provider
901-
origin: Filter by origin
902-
origin_ref: Filter by origin reference
903-
payment_method: Filter by payment method
904-
after_time: Cursor-based: only return entries older than this timestamp
905-
after_credit_ref: Cursor-based: tiebreaker credit_ref for entries with the same timestamp
906-
after_credit_index: Cursor-based: tiebreaker credit_index for entries with the same timestamp and credit_ref
907-
908-
Returns:
909-
List of credit history entries ordered by message_timestamp desc
954+
Supports dynamic sorting and cursor-based or page-based pagination.
910955
"""
911-
query = (
912-
select(AlephCreditHistoryDb)
913-
.where(AlephCreditHistoryDb.address == address)
914-
.order_by(
915-
AlephCreditHistoryDb.message_timestamp.desc(),
956+
query = select(AlephCreditHistoryDb).where(AlephCreditHistoryDb.address == address)
957+
958+
# Dynamic ordering
959+
primary_col = CREDIT_HISTORY_SORT_COLUMN_MAP[sort_by]
960+
is_desc = sort_order == SortOrder.DESCENDING
961+
962+
if sort_by in _NULLABLE_SORT_COLUMNS:
963+
if is_desc:
964+
order_primary = primary_col.desc().nullslast()
965+
else:
966+
order_primary = primary_col.asc().nullslast()
967+
else:
968+
order_primary = primary_col.desc() if is_desc else primary_col.asc()
969+
970+
# Tiebreakers for stable pagination
971+
if is_desc:
972+
query = query.order_by(
973+
order_primary,
916974
AlephCreditHistoryDb.credit_ref.desc(),
917975
AlephCreditHistoryDb.credit_index.desc(),
918976
)
919-
)
977+
else:
978+
query = query.order_by(
979+
order_primary,
980+
AlephCreditHistoryDb.credit_ref.asc(),
981+
AlephCreditHistoryDb.credit_index.asc(),
982+
)
920983

921984
# Apply filters
922-
if tx_hash is not None:
923-
query = query.where(AlephCreditHistoryDb.tx_hash == tx_hash)
924-
if token is not None:
925-
query = query.where(AlephCreditHistoryDb.token == token)
926-
if chain is not None:
927-
query = query.where(AlephCreditHistoryDb.chain == chain)
928-
if provider is not None:
929-
query = query.where(AlephCreditHistoryDb.provider == provider)
930-
if origin is not None:
931-
query = query.where(AlephCreditHistoryDb.origin == origin)
932-
if origin_ref is not None:
933-
query = query.where(AlephCreditHistoryDb.origin_ref == origin_ref)
934-
if payment_method is not None:
935-
query = query.where(AlephCreditHistoryDb.payment_method == payment_method)
985+
query = _apply_credit_history_filters(
986+
query,
987+
tx_hash=tx_hash,
988+
token=token,
989+
chain=chain,
990+
provider=provider,
991+
origin=origin,
992+
origin_ref=origin_ref,
993+
payment_method=payment_method,
994+
has_expiration=has_expiration,
995+
exclude_payment_method=exclude_payment_method,
996+
)
936997

937-
if after_time is not None:
938-
query = query.where(
939-
(AlephCreditHistoryDb.message_timestamp < after_time)
940-
| (
941-
(AlephCreditHistoryDb.message_timestamp == after_time)
942-
& (
943-
(AlephCreditHistoryDb.credit_ref < after_credit_ref)
944-
| (
945-
(AlephCreditHistoryDb.credit_ref == after_credit_ref)
946-
& (AlephCreditHistoryDb.credit_index < after_credit_index)
998+
# Cursor-based keyset pagination
999+
if after_credit_ref is not None:
1000+
if after_sort_value is None and sort_by in _NULLABLE_SORT_COLUMNS:
1001+
# Last entry had NULL sort value — only compare tiebreakers within NULL group
1002+
if is_desc:
1003+
query = query.where(
1004+
(primary_col.is_(None))
1005+
& (
1006+
(AlephCreditHistoryDb.credit_ref < after_credit_ref)
1007+
| (
1008+
(AlephCreditHistoryDb.credit_ref == after_credit_ref)
1009+
& (AlephCreditHistoryDb.credit_index < after_credit_index)
1010+
)
9471011
)
9481012
)
1013+
else:
1014+
query = query.where(
1015+
(primary_col.is_(None))
1016+
& (
1017+
(AlephCreditHistoryDb.credit_ref > after_credit_ref)
1018+
| (
1019+
(AlephCreditHistoryDb.credit_ref == after_credit_ref)
1020+
& (AlephCreditHistoryDb.credit_index > after_credit_index)
1021+
)
1022+
)
1023+
)
1024+
elif is_desc:
1025+
query = query.where(
1026+
(primary_col < after_sort_value)
1027+
| (
1028+
(primary_col == after_sort_value)
1029+
& (
1030+
(AlephCreditHistoryDb.credit_ref < after_credit_ref)
1031+
| (
1032+
(AlephCreditHistoryDb.credit_ref == after_credit_ref)
1033+
& (AlephCreditHistoryDb.credit_index < after_credit_index)
1034+
)
1035+
)
1036+
)
1037+
# NULLs come after all non-NULLs due to NULLS LAST ordering
1038+
| (primary_col.is_(None))
1039+
)
1040+
else:
1041+
query = query.where(
1042+
(primary_col > after_sort_value)
1043+
| (
1044+
(primary_col == after_sort_value)
1045+
& (
1046+
(AlephCreditHistoryDb.credit_ref > after_credit_ref)
1047+
| (
1048+
(AlephCreditHistoryDb.credit_ref == after_credit_ref)
1049+
& (AlephCreditHistoryDb.credit_index > after_credit_index)
1050+
)
1051+
)
1052+
)
1053+
# NULLs come after all non-NULLs due to NULLS LAST ordering
1054+
| (primary_col.is_(None))
9491055
)
950-
)
9511056

952-
if after_time is not None or cursor_mode:
1057+
if after_credit_ref is not None or cursor_mode:
9531058
if pagination > 0:
9541059
query = query.limit(pagination + 1)
9551060
elif pagination > 0:
@@ -968,43 +1073,28 @@ def count_address_credit_history(
9681073
origin: Optional[str] = None,
9691074
origin_ref: Optional[str] = None,
9701075
payment_method: Optional[str] = None,
1076+
has_expiration: Optional[bool] = None,
1077+
exclude_payment_method: Optional[List[str]] = None,
9711078
) -> int:
9721079
"""
9731080
Count total credit history entries for a specific address with optional filters.
974-
975-
Args:
976-
session: Database session
977-
address: Address to count credit history for
978-
tx_hash: Filter by transaction hash
979-
token: Filter by token
980-
chain: Filter by chain
981-
provider: Filter by provider
982-
origin: Filter by origin
983-
origin_ref: Filter by origin reference
984-
payment_method: Filter by payment method
985-
986-
Returns:
987-
Total number of credit history entries for the address matching the filters
9881081
"""
9891082
query = select(func.count(AlephCreditHistoryDb.credit_ref)).where(
9901083
AlephCreditHistoryDb.address == address
9911084
)
9921085

993-
# Apply filters
994-
if tx_hash is not None:
995-
query = query.where(AlephCreditHistoryDb.tx_hash == tx_hash)
996-
if token is not None:
997-
query = query.where(AlephCreditHistoryDb.token == token)
998-
if chain is not None:
999-
query = query.where(AlephCreditHistoryDb.chain == chain)
1000-
if provider is not None:
1001-
query = query.where(AlephCreditHistoryDb.provider == provider)
1002-
if origin is not None:
1003-
query = query.where(AlephCreditHistoryDb.origin == origin)
1004-
if origin_ref is not None:
1005-
query = query.where(AlephCreditHistoryDb.origin_ref == origin_ref)
1006-
if payment_method is not None:
1007-
query = query.where(AlephCreditHistoryDb.payment_method == payment_method)
1086+
query = _apply_credit_history_filters(
1087+
query,
1088+
tx_hash=tx_hash,
1089+
token=token,
1090+
chain=chain,
1091+
provider=provider,
1092+
origin=origin,
1093+
origin_ref=origin_ref,
1094+
payment_method=payment_method,
1095+
has_expiration=has_expiration,
1096+
exclude_payment_method=exclude_payment_method,
1097+
)
10081098

10091099
return session.execute(query).scalar_one()
10101100

src/aleph/schemas/api/accounts.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from aleph.schemas.messages_query_params import DEFAULT_PAGE, LIST_FIELD_SEPARATOR
99
from aleph.types.files import FileType
10-
from aleph.types.sort_order import SortOrder
10+
from aleph.types.sort_order import SortByCreditHistory, SortOrder
1111

1212

1313
class GetAccountQueryParams(BaseModel):
@@ -158,6 +158,30 @@ class GetAccountCreditHistoryQueryParams(BaseModel):
158158
payment_method: Optional[str] = Field(
159159
default=None, description="Filter by payment method"
160160
)
161+
has_expiration: Optional[bool] = Field(
162+
default=None,
163+
description="Filter by presence of expiration_date. "
164+
"true: only entries with an expiration date, "
165+
"false: only entries without an expiration date.",
166+
)
167+
exclude_payment_method: Optional[List[str]] = Field(
168+
default=None,
169+
description="Exclude entries matching these payment methods (comma-separated).",
170+
)
171+
sort_by: SortByCreditHistory = Field(
172+
default=SortByCreditHistory.MESSAGE_TIMESTAMP,
173+
description="Field to sort by.",
174+
)
175+
sort_order: SortOrder = Field(
176+
default=SortOrder.DESCENDING,
177+
description="Sort direction: 1 (ASC) or -1 (DESC).",
178+
)
179+
180+
@field_validator("exclude_payment_method", mode="before")
181+
def split_exclude_payment_method(cls, v):
182+
if isinstance(v, str):
183+
return v.split(LIST_FIELD_SEPARATOR)
184+
return v
161185

162186

163187
class CreditHistoryResponseItem(BaseModel):

src/aleph/toolkit/cursor.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,47 @@ def decode_credit_history_cursor(cursor: str) -> Tuple[dt.datetime, str, int]:
9090
raise ValueError("Invalid cursor: missing required fields")
9191

9292

93+
def encode_credit_history_sort_cursor(
94+
sort_by: str,
95+
sort_value: Any,
96+
sort_order: int,
97+
credit_ref: str,
98+
credit_index: int,
99+
) -> str:
100+
"""Encode a credit history cursor with sort field and order info."""
101+
value = (
102+
sort_value.isoformat() if isinstance(sort_value, dt.datetime) else sort_value
103+
)
104+
return encode_cursor(
105+
{"s": sort_by, "v": value, "o": sort_order, "r": credit_ref, "i": credit_index}
106+
)
107+
108+
109+
def decode_credit_history_sort_cursor(
110+
cursor: str,
111+
) -> Tuple[str, Any, int, str, int]:
112+
"""Decode a credit history sort cursor.
113+
114+
Returns (sort_by, sort_value, sort_order, credit_ref, credit_index).
115+
For backward compat, if 's' key is missing, assumes 'message_timestamp' sort
116+
with DESC order and uses the 't' key as the sort value.
117+
"""
118+
try:
119+
d = decode_cursor(cursor)
120+
if "s" in d:
121+
return (
122+
str(d["s"]),
123+
d["v"],
124+
int(d["o"]),
125+
str(d["r"]),
126+
int(d["i"]),
127+
)
128+
# Backward compat: old cursor format with (t, r, i)
129+
return "message_timestamp", d["t"], -1, str(d["r"]), int(d["i"])
130+
except KeyError:
131+
raise ValueError("Invalid cursor: missing required fields")
132+
133+
93134
def encode_address_stats_cursor(sort_value: Any, address: str) -> str:
94135
"""Encode an address stats cursor: (sort_value, address)."""
95136
return encode_cursor({"v": sort_value, "a": address})

0 commit comments

Comments
 (0)