227 lines
7.4 KiB
Python
227 lines
7.4 KiB
Python
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 ..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,
|
|
sql: str,
|
|
limit: int = 200,
|
|
) -> tuple[List[str], List[Dict[str, Any]]]:
|
|
if backend != "sqlite":
|
|
raise NotImplementedError(f"records query backend not supported yet: {backend}")
|
|
if not cls._is_read_only_sql(sql):
|
|
raise ValueError("Only read-only SQL is allowed")
|
|
|
|
path = db_path
|
|
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
|
|
|
|
@classmethod
|
|
def records_summary(
|
|
cls,
|
|
*,
|
|
backend: str,
|
|
db_path: str,
|
|
) -> Dict[str, Any]:
|
|
summary: Dict[str, Any] = {
|
|
"backend": backend,
|
|
"db_path": db_path,
|
|
"db_exists": os.path.exists(db_path),
|
|
"tables": [],
|
|
"sample_bindings": [],
|
|
}
|
|
if not os.path.exists(db_path):
|
|
return summary
|
|
if backend != "sqlite":
|
|
summary["error"] = f"records summary backend not supported yet: {backend}"
|
|
return summary
|
|
|
|
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
|
|
|
|
@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.sqlite_backend import SQLitePersistenceBackend
|
|
|
|
register_persistence_backend("sqlite", lambda config: SQLitePersistenceBackend(config), override=True)
|
|
|
|
|
|
_register_builtin_persistence_backends()
|