313 lines
11 KiB
Python
313 lines
11 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 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()
|