Skip to content

Commit 7ae1b99

Browse files
ImTotemclaude
andcommitted
feat(filter): multi-column sort support
Replace sortBy/sortOrder with sorts: [SortFieldInput!] for multi-column sorting. SQL injection safe — only valid column names accepted via _valid_columns check. GraphQL: sorts: [{field: "name", order: "asc"}, {field: "id", order: "desc"}] Default: [{field: "id", order: "asc"}] when sorts is null. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c11542d commit 7ae1b99

File tree

6 files changed

+39
-14
lines changed

6 files changed

+39
-14
lines changed

src/bcsd_api/filter/base.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,20 @@
55
T = TypeVar("T")
66

77

8+
class SortField(BaseModel):
9+
field: str
10+
order: str = "asc"
11+
12+
813
class BaseFilter(BaseModel):
914
page: int = Field(1, ge=1)
1015
size: int = Field(20, ge=1, le=100)
11-
sort_by: str = "id"
12-
sort_order: str = Field("asc", pattern="^(asc|desc)$")
16+
sorts: list[SortField] = Field(default=[SortField(field="id")])
1317

1418
search_fields: list[str] = Field(default=[], exclude=True)
1519

1620
def filters(self) -> dict:
17-
excluded = {"page", "size", "sort_by", "sort_order", "search_fields"}
21+
excluded = {"page", "size", "sorts", "search_fields"}
1822
pairs = self.model_dump(exclude=excluded, exclude_none=True)
1923
return pairs
2024

@@ -52,10 +56,14 @@ def _valid_columns(rows: list[dict]) -> set[str]:
5256

5357

5458
def _sort_rows(rows: list[dict], filt: BaseFilter) -> list[dict]:
55-
if filt.sort_by not in _valid_columns(rows):
59+
valid = _valid_columns(rows)
60+
safe = [s for s in filt.sorts if s.field in valid]
61+
if not safe:
5662
return rows
57-
reverse = filt.sort_order == "desc"
58-
return sorted(rows, key=lambda r: r.get(filt.sort_by, ""), reverse=reverse)
63+
for s in reversed(safe):
64+
reverse = s.order == "desc"
65+
rows = sorted(rows, key=lambda r, f=s.field: r.get(f, ""), reverse=reverse)
66+
return rows
5967

6068

6169
def apply_filter(rows: list[dict], filt: BaseFilter) -> PagedResponse:

src/bcsd_api/graphql/convert.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
1+
import strawberry
12
from pydantic import BaseModel
23

4+
from bcsd_api.filter.base import SortField
5+
6+
7+
@strawberry.input
8+
class SortFieldInput:
9+
field: str
10+
order: str = "asc"
11+
12+
13+
def to_sorts(inputs: list[SortFieldInput] | None) -> list[SortField]:
14+
if not inputs:
15+
return [SortField(field="id")]
16+
return [SortField(field=s.field, order=s.order) for s in inputs]
17+
318

419
def from_model(source: BaseModel, target_cls):
520
return target_cls(**source.model_dump())

src/bcsd_api/member/resolvers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from bcsd_api.filter.members import MemberFilter
55
from bcsd_api.graphql.context import GqlContext, require_user
6-
from bcsd_api.graphql.convert import from_model, from_paged
6+
from bcsd_api.graphql.convert import from_model, from_paged, to_sorts
77

88
from . import service
99
from .types import (
@@ -19,7 +19,7 @@
1919
def _to_filter(inp: MemberFilterInput) -> MemberFilter:
2020
return MemberFilter(
2121
page=inp.page, size=inp.size,
22-
sort_by=inp.sort_by, sort_order=inp.sort_order,
22+
sorts=to_sorts(inp.sorts),
2323
status=inp.status, track=inp.track,
2424
team=inp.team, payment_status=inp.payment_status,
2525
name=inp.name,

src/bcsd_api/member/types.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import strawberry
22

3+
from bcsd_api.graphql.convert import SortFieldInput
4+
35

46
@strawberry.type
57
class MemberType:
@@ -48,8 +50,7 @@ class PagedMembers:
4850
class MemberFilterInput:
4951
page: int = 1
5052
size: int = 20
51-
sort_by: str = "id"
52-
sort_order: str = "asc"
53+
sorts: list[SortFieldInput] | None = None
5354
status: str | None = None
5455
track: str | None = None
5556
team: str | None = None

src/bcsd_api/shorten/resolvers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from bcsd_api.filter.links import LinkFilter
55
from bcsd_api.graphql.context import GqlContext, require_user
6-
from bcsd_api.graphql.convert import from_model, from_paged
6+
from bcsd_api.graphql.convert import from_model, from_paged, to_sorts
77

88
from . import service
99
from .schema import CreateRequest, UpdateRequest
@@ -22,7 +22,7 @@
2222
def _to_filter(inp: LinkFilterInput) -> LinkFilter:
2323
return LinkFilter(
2424
page=inp.page, size=inp.size,
25-
sort_by=inp.sort_by, sort_order=inp.sort_order,
25+
sorts=to_sorts(inp.sorts),
2626
creator_id=inp.creator_id, expired=inp.expired,
2727
)
2828

src/bcsd_api/shorten/types.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import strawberry
44

5+
from bcsd_api.graphql.convert import SortFieldInput
6+
57

68
@strawberry.type
79
class LinkType:
@@ -53,8 +55,7 @@ class PagedLinks:
5355
class LinkFilterInput:
5456
page: int = 1
5557
size: int = 20
56-
sort_by: str = "id"
57-
sort_order: str = "asc"
58+
sorts: list[SortFieldInput] | None = None
5859
creator_id: str | None = None
5960
expired: str | None = None
6061

0 commit comments

Comments
 (0)