"""练习 4 参考答案 · 存储层:Protocol 接口 + 内存实现 + SQLite 实现。
用法:uv run pytest exercises/week4/w4_04_refactor_kata
-q(先把四个答案文件复制进练习目录)
说明:两个实现的方法名、参数、返回值完全一样,所以同一套测试能跑两遍;
SQL 全部用 `?` 参数化,写操作放在 `with self._conn:` 里(异常时自动回滚)。
"""
import sqlite3
from dataclasses import replace
from pathlib import Path
from typing import Protocol
from domain import Note
_SCHEMA = """
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
tags TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
_COLUMNS = "id, title, body, tags, created_at"
class NoteRepository(Protocol):
"""笔记存储接口:谁实现了这五个方法,谁就能被 NoteService 使用。
例:InMemoryNoteRepository() 与 SqliteNoteRepository(path) 都满足它
"""
def add(self, note: Note) -> Note:
"""存一条笔记(忽略传入的 id,分配新 id 并返回新对象)。"""
...
def get(self, note_id: int) -> Note | None:
"""按 id 取笔记;没有就返回 None("找不到"是不是错误由上层决定)。"""
...
def list_all(self) -> list[Note]:
"""按 id 升序返回全部笔记。"""
...
def search(self, keyword: str) -> list[Note]:
"""标题或正文包含 keyword 的笔记(忽略大小写)。"""
...
def delete(self, note_id: int) -> bool:
"""删除笔记;真的删掉了返回 True,本来就没有返回 False。"""
...
class InMemoryNoteRepository:
"""内存实现:一个 dict 加一个自增 id,测试首选。
例:InMemoryNoteRepository().add(note).id -> 1
"""
def __init__(self) -> None:
"""建一个空存储。"""
self._notes: dict[int, Note] = {}
self._next_id = 1
def add(self, note: Note) -> Note:
"""存一条笔记并返回带新 id 的副本。
例:add(Note(0, "t", "", (), "")).id -> 1
"""
saved = replace(note, id=self._next_id)
self._notes[saved.id] = saved
self._next_id += 1
return saved
def get(self, note_id: int) -> Note | None:
"""按 id 取笔记;没有返回 None。
例:空仓储 get(1) -> None
"""
return self._notes.get(note_id)
def list_all(self) -> list[Note]:
"""按 id 升序返回全部笔记。
例:add 三条后 [n.id for n in list_all()] -> [1, 2, 3]
"""
return [self._notes[note_id] for note_id in sorted(self._notes)]
def search(self, keyword: str) -> list[Note]:
"""标题或正文包含 keyword 的笔记(忽略大小写)。
例:search("python") 能命中标题 'Python 笔记'
"""
needle = keyword.lower()
return [
note
for note in self.list_all()
if needle in note.title.lower() or needle in note.body.lower()
]
def delete(self, note_id: int) -> bool:
"""删除笔记;删掉了返回 True,本来没有返回 False。
例:delete(1) 两次 -> True, False
"""
return self._notes.pop(note_id, None) is not None
class SqliteNoteRepository:
"""SQLite 实现:同样的五个方法,换的只是存储介质。
例:repo = SqliteNoteRepository(Path("notes.db"));用完 repo.close()
"""
def __init__(self, db_path: Path | str) -> None:
"""连上数据库并建表(表不存在时才建)。"""
self._conn = sqlite3.connect(db_path)
with self._conn:
self._conn.execute(_SCHEMA)
def close(self) -> None:
"""关连接(谁打开谁负责关;真实项目里交给 lifespan/上下文管理器)。
例:repo.close() 之后不能再用它
"""
self._conn.close()
def add(self, note: Note) -> Note:
"""插入一条笔记并返回带自增 id 的副本(参数化查询,标题里有引号也没事)。
例:add(Note(0, "it's fine", "", (), "...")).title -> "it's fine"
"""
with self._conn:
cursor = self._conn.execute(
"INSERT INTO notes (title, body, tags, created_at) VALUES (?, ?, ?, ?)",
(note.title, note.body, ",".join(note.tags), note.created_at),
)
return replace(note, id=int(cursor.lastrowid or 0))
def get(self, note_id: int) -> Note | None:
"""按 id 查一条;没有返回 None。
例:get(999) -> None
"""
row = self._conn.execute(
f"SELECT {_COLUMNS} FROM notes WHERE id = ?", (note_id,)
).fetchone()
return _row_to_note(row) if row is not None else None
def list_all(self) -> list[Note]:
"""按 id 升序查全部。
例:[n.title for n in list_all()] -> ['A', 'B']
"""
rows = self._conn.execute(
f"SELECT {_COLUMNS} FROM notes ORDER BY id"
).fetchall()
return [_row_to_note(row) for row in rows]
def search(self, keyword: str) -> list[Note]:
"""标题或正文 LIKE 关键词(忽略大小写);`?` 参数化,不拼字符串。
例:search("python") -> [Note(...)]
"""
pattern = f"%{keyword.lower()}%"
rows = self._conn.execute(
f"SELECT {_COLUMNS} FROM notes "
"WHERE lower(title) LIKE ? OR lower(body) LIKE ? ORDER BY id",
(pattern, pattern),
).fetchall()
return [_row_to_note(row) for row in rows]
def delete(self, note_id: int) -> bool:
"""删除一条;受影响行数为 0 说明本来就没有。
例:delete(1) -> True
"""
with self._conn:
cursor = self._conn.execute("DELETE FROM notes WHERE id = ?", (note_id,))
return cursor.rowcount > 0
def _row_to_note(row: tuple[int, str, str, str, str]) -> Note:
"""把一行 SQL 结果映射成领域对象(边界处映射就写在这里)。
例:_row_to_note((1, "t", "", "python,web", "...")).tags -> ('python', 'web')
"""
note_id, title, body, tags, created_at = row
return Note(
id=note_id,
title=title,
body=body,
tags=tuple(tag for tag in tags.split(",") if tag),
created_at=created_at,
)