from __future__ import annotations import os import sqlite3 from abc import ABC, abstractmethod from typing import Any, Callable, ClassVar, Dict, List, Optional, TYPE_CHECKING from sqlalchemy import create_engine, inspect, text from sqlalchemy.engine import URL, make_url from ..config.persist_config import PersistConfig if TYPE_CHECKING: from .sync_node import SyncNode class PersistenceBackend(ABC): """同步系统持久化契约。""" backend_name: ClassVar[str] = "abstract" @staticmethod def _is_read_only_sql(sql: str) -> bool: stmt = (sql or "").strip().lower() return stmt.startswith("select") or stmt.startswith("pragma") or stmt.startswith("with") @classmethod def query_records( cls, *, backend: str, db_path: str | None = None, url: str | None = None, sql: str, limit: int = 200, ) -> tuple[List[str], List[Dict[str, Any]]]: if not cls._is_read_only_sql(sql): raise ValueError("Only read-only SQL is allowed") if backend == "sqlite": path = db_path if not path: raise ValueError("db_path is required for backend=sqlite") if not os.path.exists(path): raise FileNotFoundError(f"DB not found: {path}") with sqlite3.connect(path) as conn: conn.row_factory = sqlite3.Row cursor = conn.execute(sql) rows = cursor.fetchmany(max(1, min(limit, 2000))) columns = [item[0] for item in (cursor.description or [])] data = [{k: row[k] for k in columns} for row in rows] return columns, data if backend == "sqlalchemy": if not url: raise ValueError("url is required for backend=sqlalchemy") engine = create_engine(cls._to_sync_sqlalchemy_url(url)) try: with engine.connect() as conn: result = conn.execute(text(sql)) rows = result.fetchmany(max(1, min(limit, 2000))) columns = list(result.keys()) data = [{column: row[index] for index, column in enumerate(columns)} for row in rows] return columns, data finally: engine.dispose() raise NotImplementedError(f"records query backend not supported yet: {backend}") @classmethod def records_summary( cls, *, backend: str, db_path: str | None = None, url: str | None = None, ) -> Dict[str, Any]: db_exists = cls._target_exists(backend=backend, db_path=db_path, url=url) summary: Dict[str, Any] = { "backend": backend, "db_path": db_path, "url": url, "db_exists": db_exists, "tables": [], "sample_bindings": [], } if not db_exists: return summary if backend == "sqlite": assert db_path is not None with sqlite3.connect(db_path) as conn: conn.row_factory = sqlite3.Row tables = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" ).fetchall() table_names = [str(r["name"]) for r in tables] for name in table_names: count = conn.execute(f"SELECT COUNT(1) AS c FROM {name}").fetchone()["c"] summary["tables"].append({"name": name, "count": int(count)}) if "bindings" in table_names: sample = conn.execute( "SELECT node_type, local_id, remote_id FROM bindings ORDER BY last_updated DESC LIMIT 50" ).fetchall() summary["sample_bindings"] = [ { "node_type": row["node_type"], "local_id": row["local_id"], "remote_id": row["remote_id"], } for row in sample ] return summary if backend == "sqlalchemy": if not url: summary["error"] = "url is required for backend=sqlalchemy" return summary engine = create_engine(cls._to_sync_sqlalchemy_url(url)) try: with engine.connect() as conn: inspector = inspect(conn) table_names = sorted(inspector.get_table_names()) for name in table_names: count = conn.execute(text(f"SELECT COUNT(1) AS c FROM {name}")).scalar_one() summary["tables"].append({"name": name, "count": int(count)}) if "bindings" in table_names: sample = conn.execute( text("SELECT node_type, local_id, remote_id FROM bindings ORDER BY last_updated DESC LIMIT 50") ).fetchall() summary["sample_bindings"] = [ { "node_type": row[0], "local_id": row[1], "remote_id": row[2], } for row in sample ] finally: engine.dispose() return summary summary["error"] = f"records summary backend not supported yet: {backend}" return summary @staticmethod def _target_exists(*, backend: str, db_path: str | None, url: str | None) -> bool: if backend == "sqlite": return bool(db_path) and os.path.exists(db_path) if backend == "sqlalchemy": if not url: return False parsed = make_url(url) if parsed.get_backend_name() == "sqlite": database = parsed.database if not database or database == ":memory:": return True return os.path.exists(database) return True return False @staticmethod def _to_sync_sqlalchemy_url(url: str) -> str: parsed = make_url(url) async_to_sync = { "sqlite+aiosqlite": "sqlite+pysqlite", "postgresql+asyncpg": "postgresql+psycopg", "mysql+aiomysql": "mysql+pymysql", } drivername = async_to_sync.get(parsed.drivername, parsed.drivername) sync_url: URL = parsed.set(drivername=drivername) return str(sync_url) @abstractmethod async def initialize(self) -> None: raise NotImplementedError @abstractmethod async def save_node(self, collection_id: str, scope: str, node: "SyncNode") -> None: raise NotImplementedError @abstractmethod async def save_nodes_bulk(self, collection_id: str, scope: str, nodes: List["SyncNode"]) -> None: raise NotImplementedError @abstractmethod async def delete_node(self, collection_id: str, scope: str, node_id: str) -> None: raise NotImplementedError @abstractmethod async def delete_nodes_bulk(self, collection_id: str, scope: str, node_ids: List[str]) -> None: raise NotImplementedError @abstractmethod async def load_nodes( self, collection_id: str, scope: str, *, exclude_node_types: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: raise NotImplementedError @abstractmethod async def save_binding(self, scope: str, node_type: str, local_id: str, payload_dict: Dict[str, Any]) -> None: raise NotImplementedError @abstractmethod async def save_bindings_bulk(self, scope: str, node_type: str, bindings: Dict[str, Optional[str]]) -> None: raise NotImplementedError @abstractmethod async def delete_binding(self, scope: str, node_type: str, local_id: str) -> None: raise NotImplementedError @abstractmethod async def delete_bindings_bulk(self, scope: str, node_type: str, local_ids: List[str]) -> None: raise NotImplementedError @abstractmethod async def load_bindings(self, scope: str, node_type: str) -> List[Dict[str, Any]]: raise NotImplementedError @abstractmethod async def load_bind_data_id_overrides(self, node_type: str, scope: str) -> Dict[str, Optional[str]]: raise NotImplementedError @abstractmethod async def delete_scope_node_types(self, *, scope: str, node_types: List[str]) -> None: raise NotImplementedError @abstractmethod async def copy_nodes_between_scopes( self, *, collection_id: str, source_scope: str, target_scope: str, node_types: List[str], ) -> None: raise NotImplementedError @abstractmethod async def copy_bindings_between_scopes( self, *, source_scope: str, target_scope: str, node_types: List[str], ) -> None: raise NotImplementedError @abstractmethod async def dump_to_runtime(self, filename: str) -> None: raise NotImplementedError @abstractmethod async def close(self) -> None: raise NotImplementedError PersistenceBackendFactory = Callable[[PersistConfig], PersistenceBackend] _PERSISTENCE_BACKEND_REGISTRY: dict[str, PersistenceBackendFactory] = {} def register_persistence_backend(name: str, factory: PersistenceBackendFactory, *, override: bool = False) -> None: backend_name = name.strip() if not backend_name: raise ValueError("backend name cannot be empty") if not override and backend_name in _PERSISTENCE_BACKEND_REGISTRY: raise ValueError(f"persistence backend already registered: {backend_name}") _PERSISTENCE_BACKEND_REGISTRY[backend_name] = factory def get_registered_persistence_backends() -> Dict[str, PersistenceBackendFactory]: return dict(_PERSISTENCE_BACKEND_REGISTRY) def create_persistence_backend( persist_config: PersistConfig, ) -> PersistenceBackend: backend_name = (persist_config.backend or "").strip() if not backend_name: raise ValueError("persist.backend cannot be empty") if not _PERSISTENCE_BACKEND_REGISTRY: register_persistence_backend( "sqlite", lambda config: SQLitePersistenceBackend(config), ) factory = _PERSISTENCE_BACKEND_REGISTRY.get(backend_name) if factory is None: available = ", ".join(sorted(_PERSISTENCE_BACKEND_REGISTRY)) or "none" raise NotImplementedError(f"Persistence backend not supported yet: {backend_name}. Available: {available}") return factory(persist_config) def _register_builtin_persistence_backends() -> None: from .persistence_backends.sqlalchemy_backend import SQLAlchemyPersistenceBackend from .persistence_backends.sqlite_backend import SQLitePersistenceBackend register_persistence_backend("sqlite", lambda config: SQLitePersistenceBackend(config), override=True) register_persistence_backend("sqlalchemy", lambda config: SQLAlchemyPersistenceBackend(config), override=True) _register_builtin_persistence_backends()