Skip to content

Commit a08e78e

Browse files
PttCodingManclaude
andcommitted
refactor: share search/acl helpers, lift lazy imports
- services/search: hoist LIKE_ESCAPE and escape_like out of routers so the FTS-fallback escape logic lives next to build_fts_query / build_like_words. routers/search and routers/ai now import the shared helpers instead of carrying byte-identical copies - services/acl: hoist _build_id_clause out of routers/pages as build_id_clause; routers/activity also switches to it so activity_stats no longer hits SQLITE_MAX_VARIABLE_NUMBER (default 999) on a wiki where the caller has more readable pages than that - routers/pages: lift the per-request `from app.services.notifications import ...` out of create/update/delete page bodies to the module- level imports — deferred imports inside hot paths hide dependencies and break static analysis - migrations: reorder _m010_site_settings to appear before _m011_mindmap_layout in the file so function order matches list order (execution order was already correct) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9d8ba42 commit a08e78e

7 files changed

Lines changed: 75 additions & 75 deletions

File tree

backend/app/migrations.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -150,17 +150,6 @@ async def _m009_page_type(db: aiosqlite.Connection) -> None:
150150
)
151151

152152

153-
async def _m011_mindmap_layout(db: aiosqlite.Connection) -> None:
154-
"""Add `mindmap_layout` to pages so authors can pick LR / RL / Radial.
155-
156-
Nullable TEXT — NULL means "use the frontend default" (`'lr'`), so legacy
157-
rows render unchanged. Validation is enforced by Pydantic Literal in
158-
schemas.MindmapLayout, not at the DB layer (matches `page_type`).
159-
"""
160-
if not await _column_exists(db, "pages", "mindmap_layout"):
161-
await db.execute("ALTER TABLE pages ADD COLUMN mindmap_layout TEXT")
162-
163-
164153
async def _m010_site_settings(db: aiosqlite.Connection) -> None:
165154
"""Create site_settings for branding overrides and the home-page slug.
166155
@@ -179,6 +168,17 @@ async def _m010_site_settings(db: aiosqlite.Connection) -> None:
179168
)
180169

181170

171+
async def _m011_mindmap_layout(db: aiosqlite.Connection) -> None:
172+
"""Add `mindmap_layout` to pages so authors can pick LR / RL / Radial.
173+
174+
Nullable TEXT — NULL means "use the frontend default" (`'lr'`), so legacy
175+
rows render unchanged. Validation is enforced by Pydantic Literal in
176+
schemas.MindmapLayout, not at the DB layer (matches `page_type`).
177+
"""
178+
if not await _column_exists(db, "pages", "mindmap_layout"):
179+
await db.execute("ALTER TABLE pages ADD COLUMN mindmap_layout TEXT")
180+
181+
182182
MIGRATIONS: list[Migration] = [
183183
(1, "user_profile_columns", _m001_user_profile_columns),
184184
(2, "user_soft_delete", _m002_user_soft_delete),

backend/app/routers/activity.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from fastapi import APIRouter, Depends, Query
33
from app.auth import get_current_user
44
from app.database import get_db
5-
from app.services.acl import list_readable_page_ids
5+
from app.services.acl import build_id_clause, list_readable_page_ids
66

77
router = APIRouter(prefix="/api/activity", tags=["activity"])
88

@@ -28,10 +28,10 @@ def _readable_clause(readable: frozenset[int] | set[int]) -> tuple[str, list]:
2828
if not readable:
2929
# No readable pages → drop every page-targeted row.
3030
return "(a.target_type != 'page')", []
31-
placeholders = ",".join("?" * len(readable))
31+
id_clause, id_params = build_id_clause(readable, column="a.target_id")
3232
return (
33-
f"(a.target_type != 'page' OR a.target_id IN ({placeholders}))",
34-
list(readable),
33+
f"(a.target_type != 'page' OR {id_clause})",
34+
id_params,
3535
)
3636

3737

@@ -99,32 +99,32 @@ async def activity_stats(user=Depends(get_current_user)):
9999
"total_users": 0,
100100
}
101101

102-
placeholders = ",".join("?" * len(readable))
103-
readable_params = list(readable)
102+
id_clause, id_params = build_id_clause(readable)
103+
p_id_clause, p_id_params = build_id_clause(readable, column="p.id")
104104

105105
top_viewed = await db.execute_fetchall(
106106
f"""SELECT id, slug, title, view_count FROM pages
107-
WHERE id IN ({placeholders})
107+
WHERE {id_clause}
108108
ORDER BY view_count DESC LIMIT 10""",
109-
readable_params,
109+
id_params,
110110
)
111111

112112
recently_updated = await db.execute_fetchall(
113113
f"""SELECT p.id, p.slug, p.title, p.updated_at
114114
FROM pages p
115-
WHERE p.id IN ({placeholders})
115+
WHERE {p_id_clause}
116116
ORDER BY p.updated_at DESC LIMIT 10""",
117-
readable_params,
117+
p_id_params,
118118
)
119119

120120
orphan_pages = await db.execute_fetchall(
121121
f"""SELECT p.id, p.slug, p.title, p.view_count
122122
FROM pages p
123-
WHERE p.id IN ({placeholders})
123+
WHERE {p_id_clause}
124124
AND p.id NOT IN (SELECT target_page_id FROM backlinks)
125125
AND p.parent_id IS NULL
126126
ORDER BY p.updated_at DESC LIMIT 20""",
127-
readable_params,
127+
p_id_params,
128128
)
129129

130130
# total_pages reflects what *this user* can read, not the global count.

backend/app/routers/ai.py

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from app.config import settings
2323
from app.database import get_db
2424
from app.services.acl import list_readable_page_ids
25-
from app.services.search import build_fts_query, build_like_words
25+
from app.services.search import LIKE_ESCAPE, build_fts_query, build_like_words, escape_like
2626

2727
# AI calls hit a paid upstream — never expose to anonymous traffic.
2828
router = APIRouter(
@@ -31,8 +31,6 @@
3131
dependencies=[Depends(require_real_user)],
3232
)
3333

34-
_LIKE_ESCAPE = "\\"
35-
3634

3735
# ── rate limiting (per user, in-memory sliding window) ─────────────────
3836
# Simple deque-per-user. Good enough for a self-hosted wiki; a multi-worker
@@ -71,14 +69,6 @@ class ChatRequest(BaseModel):
7169
# ── retrieval ──────────────────────────────────────────────────────────
7270

7371

74-
def _escape_like(s: str) -> str:
75-
return (
76-
s.replace(_LIKE_ESCAPE, _LIKE_ESCAPE * 2)
77-
.replace("%", _LIKE_ESCAPE + "%")
78-
.replace("_", _LIKE_ESCAPE + "_")
79-
)
80-
81-
8272
async def _fts_lookup(db, fts_query: str, readable_json: str, limit: int):
8373
sql = """
8474
SELECT p.slug, p.title, p.content_md
@@ -94,13 +84,13 @@ async def _fts_lookup(db, fts_query: str, readable_json: str, limit: int):
9484

9585
async def _like_lookup(db, words: list[str], readable_json: str, limit: int):
9686
like_clauses = " OR ".join(
97-
f"p.title LIKE ? ESCAPE '{_LIKE_ESCAPE}' "
98-
f"OR p.content_md LIKE ? ESCAPE '{_LIKE_ESCAPE}'"
87+
f"p.title LIKE ? ESCAPE '{LIKE_ESCAPE}' "
88+
f"OR p.content_md LIKE ? ESCAPE '{LIKE_ESCAPE}'"
9989
for _ in words
10090
)
10191
like_params: list[str] = []
10292
for w in words:
103-
pattern = f"%{_escape_like(w)}%"
93+
pattern = f"%{escape_like(w)}%"
10494
like_params.extend([pattern, pattern])
10595
sql = f"""
10696
SELECT p.slug, p.title, p.content_md

backend/app/routers/pages.py

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,17 @@
1010
from app.schemas import PageCreate, PageUpdate, PageResponse, PageListResponse, PageMoveRequest
1111
from app.auth import get_current_user
1212
from app.database import get_db, write_transaction
13-
from app.services.acl import invalidate_readable_cache, list_readable_page_ids, resolve_page_permission
13+
from app.services.acl import build_id_clause, invalidate_readable_cache, list_readable_page_ids, resolve_page_permission
1414
from app.services.search import rebuild_search_index, remove_from_search_index
1515
from app.services.wikilink import parse_and_update_backlinks
1616
from app.services.media_ref import parse_and_update_media_refs
1717
from app.routers.activity import log_activity
1818
from app.routers.versions import save_version
19+
from app.services.notifications import (
20+
notify_page_created,
21+
notify_page_deleted,
22+
notify_page_updated,
23+
)
1924

2025
router = APIRouter(prefix="/api/pages", tags=["pages"])
2126

@@ -60,20 +65,6 @@ async def _should_count_view(db, user_id: int, page_id: int) -> bool:
6065
return True
6166

6267

63-
def _build_id_clause(ids: set[int], column: str = "id") -> tuple[str, list]:
64-
"""Produce a parameterized ``column IN (SELECT value FROM json_each(?))``
65-
clause plus params.
66-
67-
Uses ``json_each`` instead of ``IN (?,?,...)`` to avoid hitting
68-
SQLite's ``SQLITE_MAX_VARIABLE_NUMBER`` limit on large page sets.
69-
For the empty set, returns a clause that never matches so downstream
70-
SQL can be composed without branching.
71-
"""
72-
if not ids:
73-
return "0 = 1", []
74-
return f"{column} IN (SELECT value FROM json_each(?))", [json.dumps(list(ids))]
75-
76-
7768
def slugify(title: str, existing_slug: str | None = None) -> str:
7869
"""Generate a URL-friendly slug from title. Preserves CJK characters so
7970
Chinese/Japanese/Korean titles show up in the URL as-is rather than pinyin."""
@@ -139,7 +130,7 @@ async def list_pages(
139130
offset = (page - 1) * per_page
140131

141132
readable = await list_readable_page_ids(db, user)
142-
id_clause, id_params = _build_id_clause(readable)
133+
id_clause, id_params = build_id_clause(readable)
143134

144135
where = f"WHERE deleted_at IS NULL AND {id_clause}"
145136
params: list = list(id_params)
@@ -164,7 +155,7 @@ async def list_pages(
164155
async def page_tree(user=Depends(get_current_user)):
165156
db = await get_db()
166157
readable = await list_readable_page_ids(db, user)
167-
id_clause, id_params = _build_id_clause(readable)
158+
id_clause, id_params = build_id_clause(readable)
168159
rows = await db.execute_fetchall(
169160
f"SELECT id, slug, title, parent_id, sort_order FROM pages "
170161
f"WHERE deleted_at IS NULL AND {id_clause} "
@@ -205,7 +196,7 @@ def build_tree(parent_id):
205196
async def page_graph(user=Depends(get_current_user)):
206197
db = await get_db()
207198
readable = await list_readable_page_ids(db, user)
208-
id_clause, id_params = _build_id_clause(readable)
199+
id_clause, id_params = build_id_clause(readable)
209200
pages = await db.execute_fetchall(
210201
f"SELECT id, slug, title, parent_id FROM pages WHERE deleted_at IS NULL AND {id_clause}",
211202
id_params,
@@ -312,7 +303,6 @@ async def create_page(body: PageCreate, user=Depends(get_current_user)):
312303
new_page = dict(rows[0])
313304

314305
# Fire notification
315-
from app.services.notifications import notify_page_created
316306
await notify_page_created(db, new_page, user)
317307

318308
return new_page
@@ -468,7 +458,6 @@ async def update_page(slug: str, body: PageUpdate, user=Depends(get_current_user
468458

469459
# Fire notifications if content/title actually changed
470460
if content_changed or title_changed:
471-
from app.services.notifications import notify_page_updated
472461
await notify_page_updated(db, updated, user, {"title_changed": title_changed, "content_changed": content_changed})
473462

474463
return updated
@@ -487,7 +476,7 @@ async def get_children(slug: str, user=Depends(get_current_user)):
487476
raise HTTPException(status_code=404, detail="Page not found")
488477

489478
readable = await list_readable_page_ids(db, user)
490-
id_clause, id_params = _build_id_clause(readable)
479+
id_clause, id_params = build_id_clause(readable)
491480
children = await db.execute_fetchall(
492481
f"SELECT * FROM pages WHERE parent_id = ? AND deleted_at IS NULL AND {id_clause} "
493482
f"ORDER BY sort_order, title",
@@ -509,7 +498,7 @@ async def get_backlinks(slug: str, user=Depends(get_current_user)):
509498
raise HTTPException(status_code=404, detail="Page not found")
510499

511500
readable = await list_readable_page_ids(db, user)
512-
id_clause, id_params = _build_id_clause(readable, column="p.id")
501+
id_clause, id_params = build_id_clause(readable, column="p.id")
513502
backlinks = await db.execute_fetchall(
514503
f"""SELECT p.id, p.slug, p.title
515504
FROM backlinks b
@@ -622,7 +611,6 @@ async def delete_page(slug: str, user=Depends(get_current_user)):
622611
invalidate_readable_cache()
623612

624613
# Fire notification
625-
from app.services.notifications import notify_page_deleted
626614
await notify_page_deleted(db, {"id": page_id, "title": page_title, "slug": slug}, user)
627615

628616
return {"ok": True}

backend/app/routers/search.py

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,10 @@
55
from app.auth import get_current_user
66
from app.database import get_db
77
from app.services.acl import list_readable_page_ids
8-
from app.services.search import build_fts_query, build_like_words
8+
from app.services.search import LIKE_ESCAPE, build_fts_query, build_like_words, escape_like
99

1010
router = APIRouter(prefix="/api/search", tags=["search"])
1111

12-
# LIKE wildcards that need escaping so user input like "100%" is treated literally.
13-
_LIKE_ESCAPE = "\\"
14-
15-
16-
def _escape_like(s: str) -> str:
17-
return (
18-
s.replace(_LIKE_ESCAPE, _LIKE_ESCAPE * 2)
19-
.replace("%", _LIKE_ESCAPE + "%")
20-
.replace("_", _LIKE_ESCAPE + "_")
21-
)
22-
2312

2413
def make_snippet(text: str, query: str, max_len: int = 200) -> str:
2514
"""Extract a snippet around the first match and wrap matches in <mark>."""
@@ -118,14 +107,14 @@ async def _search_like(db, words, tag, readable_json, per_page, offset):
118107
`%` and `_` in user input are escaped so they are treated literally.
119108
"""
120109
like_clauses = " OR ".join(
121-
f"p.title LIKE ? ESCAPE '{_LIKE_ESCAPE}' "
122-
f"OR p.content_md LIKE ? ESCAPE '{_LIKE_ESCAPE}'"
110+
f"p.title LIKE ? ESCAPE '{LIKE_ESCAPE}' "
111+
f"OR p.content_md LIKE ? ESCAPE '{LIKE_ESCAPE}'"
123112
for _ in words
124113
)
125114
like_clauses = f"({like_clauses})"
126115
like_params = []
127116
for w in words:
128-
pattern = f"%{_escape_like(w)}%"
117+
pattern = f"%{escape_like(w)}%"
129118
like_params.extend([pattern, pattern])
130119

131120
if tag:

backend/app/services/acl.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
(no live references) is accessible to its uploader and admins only.
2121
"""
2222

23+
import json
2324
import time
2425

2526
from fastapi import HTTPException
@@ -36,6 +37,20 @@
3637
_READABLE_CACHE_TTL = 30 # seconds
3738

3839

40+
def build_id_clause(ids, column: str = "id") -> tuple[str, list]:
41+
"""SQL fragment + params for ``{column} IN (SELECT value FROM json_each(?))``.
42+
43+
Uses ``json_each`` rather than inline ``IN (?,?,...)`` to avoid hitting
44+
SQLite's ``SQLITE_MAX_VARIABLE_NUMBER`` (default 999) when callers feed
45+
in the full readable-page set on a wiki with thousands of pages. For an
46+
empty set returns a clause that never matches so downstream SQL can be
47+
composed without branching.
48+
"""
49+
if not ids:
50+
return "0 = 1", []
51+
return f"{column} IN (SELECT value FROM json_each(?))", [json.dumps(list(ids))]
52+
53+
3954
def invalidate_readable_cache(user_id: int | None = None):
4055
"""Drop cached readable-page sets.
4156

backend/app/services/search.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,24 @@ def _escape_fts_phrase(s: str) -> str:
4545
return s.replace('"', '""')
4646

4747

48+
# ── LIKE fallback escaping ─────────────────────────────────────────────────
49+
#
50+
# When FTS misses we fall back to LIKE patterns. SQLite LIKE treats `%` and
51+
# `_` as wildcards; we escape them with a backslash and tell SQLite about it
52+
# via `ESCAPE '\\'`. The escape character itself must also be escaped.
53+
54+
LIKE_ESCAPE = "\\"
55+
56+
57+
def escape_like(s: str) -> str:
58+
"""Escape `%`, `_`, and `\\` so the value is matched literally by LIKE."""
59+
return (
60+
s.replace(LIKE_ESCAPE, LIKE_ESCAPE * 2)
61+
.replace("%", LIKE_ESCAPE + "%")
62+
.replace("_", LIKE_ESCAPE + "_")
63+
)
64+
65+
4866
def build_fts_query(question: str) -> str | None:
4967
"""Build an FTS5 MATCH expression that aligns with the trigram index.
5068

0 commit comments

Comments
 (0)