refactor: address code-review — layering + public API naming
Applies the three findings from the /code-review high pass on this stack:
1. to_tsquery_str moved from app.feedquery to app.utils — it's a generic
tsquery sanitizer with no feed dependency; housing it in the feed
read-model coupled the Plex browse module to the feed domain. Both feed
and plex now import it from the neutral util module.
2. feedquery.decode_cursor no longer raises fastapi.HTTPException — it raises
a plain ValueError, and routes/feed.get_feed translates it to the HTTP 400.
The read-model no longer imports fastapi, matching its "no request state"
docstring.
3. Dropped the leading underscore on the helpers that are now a cross-module
public API: feedquery {filtered_query, sort_keys, order_by, keyset_predicate,
encode_cursor, decode_cursor}; plex.query {TS_CONFIG, FIELD_COLUMNS,
field_recheck, split_csv, added_cutoff, meta_filter_conds, apply_meta_filters,
like_escape}; plex.cards {movie_card, show_card, episode_card, leaf_card}.
Genuinely-internal helpers (_channel_url, _saved_expr, _ADDED_WINDOWS,
_lib_key) keep their underscore.
Pure code motion + rename. DB-lane suite 287 passed / 2 skipped; ruff clean.
This commit is contained in:
+15
-26
@@ -1,18 +1,17 @@
|
||||
"""Feed read-model: the pure query-building and row-serialization layer behind the feed.
|
||||
|
||||
Extracted from `routes/feed.py` so the read-model has one home instead of living inside the
|
||||
router module — the feed route handlers, the playlist-detail query, and the Plex search box all
|
||||
share these helpers (the projection, the filter builder, `to_tsquery_str`, and keyset paging).
|
||||
Nothing here defines a route or reaches for request state; it's all `(db, user, params) -> query`.
|
||||
router module — the feed route handlers and the playlist-detail query share these helpers (the
|
||||
projection, the filter builder, and keyset paging). Nothing here defines a route or reaches for
|
||||
request state; it's all `(db, user, params) -> query`, and a malformed cursor raises a plain
|
||||
`ValueError` for the route handler to translate (the tsquery sanitizer lives in `app.utils`).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
@@ -31,7 +30,7 @@ from app.models import (
|
||||
Video,
|
||||
VideoState,
|
||||
)
|
||||
from app.utils import iso
|
||||
from app.utils import iso, to_tsquery_str
|
||||
|
||||
|
||||
def _channel_url(channel_id: str, handle: str | None) -> str:
|
||||
@@ -101,7 +100,7 @@ def serialize(row) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _filtered_query(
|
||||
def filtered_query(
|
||||
db: Session,
|
||||
user: User,
|
||||
*,
|
||||
@@ -330,18 +329,6 @@ def _filtered_query(
|
||||
return query, rank_expr
|
||||
|
||||
|
||||
def to_tsquery_str(q: str) -> str | None:
|
||||
"""Turn free-text into a to_tsquery() string: word runs ANDed together, the LAST word
|
||||
prefix-matched (`:*`) for as-you-type search — e.g. "ti amo magyar" → "ti & amo & magyar:*".
|
||||
Only `\\w` runs containing an alphanumeric are kept, so no tsquery operators can leak in
|
||||
(the string is built from sanitised tokens, never the raw query)."""
|
||||
tokens = [t for t in re.findall(r"\w+", q, flags=re.UNICODE) if any(c.isalnum() for c in t)]
|
||||
if not tokens:
|
||||
return None
|
||||
tokens[-1] = tokens[-1] + ":*"
|
||||
return " & ".join(tokens)
|
||||
|
||||
|
||||
# --- Keyset (cursor) pagination ---------------------------------------------
|
||||
#
|
||||
# Each sort is described as an ordered list of key columns (expression + direction
|
||||
@@ -351,7 +338,7 @@ def to_tsquery_str(q: str) -> str | None:
|
||||
# replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows,
|
||||
# and the page boundary is stable even as the scheduler ingests new videos mid-scroll
|
||||
# (OFFSET would shift and duplicate/skip rows).
|
||||
def _sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
|
||||
def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
|
||||
pub = {"expr": Video.published_at, "asc": False, "nullable": True, "kind": "dt"}
|
||||
table: dict[str, list[dict]] = {
|
||||
"newest": [pub],
|
||||
@@ -382,7 +369,7 @@ def _sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
|
||||
return table.get(sort) or table["newest"]
|
||||
|
||||
|
||||
def _order_by(keys: list[dict]):
|
||||
def order_by(keys: list[dict]):
|
||||
cols = []
|
||||
for k in keys:
|
||||
c = k["expr"].asc() if k["asc"] else k["expr"].desc()
|
||||
@@ -391,7 +378,7 @@ def _order_by(keys: list[dict]):
|
||||
return cols
|
||||
|
||||
|
||||
def _keyset_predicate(keys: list[dict], cursor_id: str):
|
||||
def keyset_predicate(keys: list[dict], cursor_id: str):
|
||||
"""WHERE clause selecting rows strictly AFTER the cursor in the sort order.
|
||||
|
||||
Lexicographic over the key columns then id, NULL-aware for `nulls_last`: a NULL
|
||||
@@ -413,7 +400,7 @@ def _keyset_predicate(keys: list[dict], cursor_id: str):
|
||||
return or_(*branches)
|
||||
|
||||
|
||||
def _encode_cursor(keys: list[dict], row) -> str:
|
||||
def encode_cursor(keys: list[dict], row) -> str:
|
||||
vals = []
|
||||
for i, k in enumerate(keys):
|
||||
v = getattr(row, f"_k{i}")
|
||||
@@ -422,7 +409,7 @@ def _encode_cursor(keys: list[dict], row) -> str:
|
||||
return base64.urlsafe_b64encode(payload.encode()).decode()
|
||||
|
||||
|
||||
def _decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str]:
|
||||
def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str]:
|
||||
try:
|
||||
payload = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
||||
raw = payload["k"]
|
||||
@@ -433,5 +420,7 @@ def _decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str]:
|
||||
for k, v in zip(keys, raw)
|
||||
]
|
||||
return vals, payload["id"]
|
||||
except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid feed cursor")
|
||||
except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError) as exc:
|
||||
# Framework-free: the route handler translates this into an HTTP 400 so the
|
||||
# read-model stays web-layer-agnostic (see routes/feed.get_feed).
|
||||
raise ValueError("Invalid feed cursor") from exc
|
||||
|
||||
Reference in New Issue
Block a user